diff --git a/services/management/commands/update_vantaa_parking_areas.py b/services/management/commands/update_vantaa_parking_areas.py index 5f20f59c7..166c60083 100644 --- a/services/management/commands/update_vantaa_parking_areas.py +++ b/services/management/commands/update_vantaa_parking_areas.py @@ -4,10 +4,17 @@ import logging import os +from itertools import batched from time import time from django.contrib.gis.gdal import CoordTransform, SpatialReference -from django.contrib.gis.geos import GEOSGeometry, LineString, MultiPolygon, Polygon +from django.contrib.gis.geos import ( + GEOSGeometry, + LineString, + MultiLineString, + MultiPolygon, + Polygon, +) from django.core.management.base import BaseCommand from munigeo.models import ( AdministrativeDivision, @@ -68,6 +75,7 @@ ] SRC_SRID = 4326 +BATCH_SIZE = 1000 PARKING_NAME_TRANSLATIONS = { "12h-24h": {"sv": "12-24 timmar", "en": "12-24 hours"}, @@ -82,6 +90,10 @@ } +class UnsupportedGeometryError(Exception): + pass + + class Command(BaseCommand): help = "Update Vantaa parking areas from Vantaa ArcGIS Server" @@ -110,25 +122,56 @@ def transform_line_to_polygon(self, obj): return buffered_line + def _convert_polygon_to_multi(self, polygon): + """Convert a Polygon to MultiPolygon preserving SRID.""" + multi_poly = MultiPolygon(polygon) + multi_poly.srid = polygon.srid + return multi_poly + + def _convert_linestring_to_multi(self, linestring): + """Convert a LineString to MultiPolygon by buffering.""" + buffered = self.transform_line_to_polygon(linestring) + if isinstance(buffered, MultiPolygon): + return buffered + if isinstance(buffered, Polygon): + return self._convert_polygon_to_multi(buffered) + raise ValueError("Buffered geometry is not a Polygon or MultiPolygon.") + + def _convert_multilinestring_to_multi(self, multilinestring): + """Convert a MultiLineString to MultiPolygon by buffering each line.""" + polygons = [] + for line in multilinestring: + buffered = self.transform_line_to_polygon(line) + if not isinstance(buffered, (Polygon, MultiPolygon)): + raise ValueError("Buffered geometry is not a Polygon or MultiPolygon.") + + # If buffered is a MultiPolygon, extract individual Polygons + if isinstance(buffered, MultiPolygon): + polygons.extend(list(buffered)) + else: + polygons.append(buffered) + + multi_poly = MultiPolygon(polygons) + multi_poly.srid = multilinestring.srid + return multi_poly + def get_multi_geom(self, obj): """ Return the appropriate multi-container for the supplied geometry. If the geometry is already a multi-container, return the object itself. """ - if isinstance(obj, Polygon): - return MultiPolygon(obj) - elif isinstance(obj, (MultiPolygon)): + if isinstance(obj, MultiPolygon): return obj - elif isinstance(obj, (LineString)): - buffered_line = self.transform_line_to_polygon(obj) - if isinstance(buffered_line, MultiPolygon): - return buffered_line - elif isinstance(buffered_line, Polygon): - return MultiPolygon([buffered_line]) - else: - raise ValueError("Buffered geometry is not a Polygon or MultiPolygon.") - else: - raise Exception(f"Unsupported geometry type: {obj.__class__.__name__}") + if isinstance(obj, Polygon): + return self._convert_polygon_to_multi(obj) + if isinstance(obj, LineString): + return self._convert_linestring_to_multi(obj) + if isinstance(obj, MultiLineString): + return self._convert_multilinestring_to_multi(obj) + + raise UnsupportedGeometryError( + f"Unsupported geometry type: {obj.__class__.__name__}" + ) def update_parking_areas(self): src_srs = SpatialReference(SRC_SRID) @@ -154,10 +197,42 @@ def update_parking_areas(self): service = restapi.FeatureService(data_source["service_url"]) parking_areas = service.layer(data_source["layer_name"]) - features = parking_areas.query() + + # Retrieve all features using pagination to handle more than + # the record limit + features = [] + + # First, get all object IDs + all_oids_result = parking_areas.query(returnIdsOnly=True) + + # Extract object IDs from the result + if hasattr(all_oids_result, "objectIds"): + all_oids = all_oids_result.objectIds + elif isinstance(all_oids_result, dict) and "objectIds" in all_oids_result: + all_oids = all_oids_result["objectIds"] + else: + # Fallback: try to query all features at once + logger.warning( + "Could not retrieve object IDs, " + "attempting to query all features at once" + ) + features = parking_areas.query() + all_oids = [] + + # Query features in batches using object IDs + if all_oids: + all_features = [] + + for batch_oids in batched(all_oids, BATCH_SIZE): + oid_string = ",".join(map(str, batch_oids)) + batch = parking_areas.query(objectIds=oid_string) + if batch: + all_features.extend(batch) + + features = all_features readable_name = data_source["type"].replace("_", " ").strip() + "s" - logger.info(f"Found {features.count} {readable_name} for Vantaa") + logger.info(f"Found {len(features)} {readable_name} for Vantaa") logger.info(f"Importing {readable_name}...") updated_parking_areas = [] diff --git a/services/tests/data/vantaa_parking_areas.json b/services/tests/data/vantaa_parking_areas.json deleted file mode 100644 index 2d80f216c..000000000 --- a/services/tests/data/vantaa_parking_areas.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "features": [ - { - "geometry": { - "coordinates": [ - [ - [ - 25.084374753255055, - 60.3499294370369 - ], - [ - 25.08408380151095, - 60.34990982397221 - ], - [ - 25.084089858850913, - 60.34983805624883 - ], - [ - 25.084383300724973, - 60.349855193165794 - ], - [ - 25.084374753255055, - 60.3499294370369 - ] - ] - ], - "type": "Polygon" - }, - "id": 1, - "properties": { - "aikarajoitus": "30 min", - "aikarajoitus_num": null, - "aluetunnus": null, - "katu": null, - "kaupunginosa": null, - "kiekkopaikka": "Kyllä", - "lisätiedot": null, - "objectid": 1, - "objectid_1": null, - "paikkamäärä": 6, - "paikkamääräap": "6 ap", - "tyyppi": "Lyhytaikainen", - "voimassaoloaika": null - }, - "type": "Feature" - }, - { - "geometry": { - "coordinates": [ - [ - 25.049797811943435, - 60.35260110313705 - ], - [ - 25.049934474444232, - 60.35248545784165 - ], - [ - 25.05008239193723, - 60.35239648422703 - ], - [ - 25.050167558514346, - 60.35234643499014 - ], - [ - 25.05058901570631, - 60.352171773300924 - ] - ], - "type": "LineString" - }, - "id": 2, - "properties": { - "aikarajoitus": null, - "aikarajoitus_num": null, - "aluetunnus": null, - "katu": null, - "kaupunginosa": null, - "kiekkopaikka": "Ei", - "lisätiedot": null, - "objectid": 2, - "objectid_1": 2, - "paikkamäärä": 8, - "paikkamääräap": "8 ap", - "tyyppi": "Ei rajoitusta", - "voimassaoloaika": null - }, - "type": "Feature" - } - ] -} diff --git a/services/tests/data/vantaa_parking_areas_null_geometry.json b/services/tests/data/vantaa_parking_areas_null_geometry.json deleted file mode 100644 index abb36e061..000000000 --- a/services/tests/data/vantaa_parking_areas_null_geometry.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "features": [ - { - "geometry": null, - "id": 3, - "properties": { - "aikarajoitus": "30 min", - "aikarajoitus_num": null, - "aluetunnus": null, - "katu": null, - "kaupunginosa": null, - "kiekkopaikka": "Kyllä", - "lisätiedot": null, - "objectid": 3, - "objectid_1": null, - "paikkamäärä": 6, - "paikkamääräap": "6 ap", - "tyyppi": "Lyhytaikainen", - "voimassaoloaika": null - }, - "type": "Feature" - } - ] -} diff --git a/services/tests/data/vantaa_parking_payzones.json b/services/tests/data/vantaa_parking_payzones.json deleted file mode 100644 index 45620f5c8..000000000 --- a/services/tests/data/vantaa_parking_payzones.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "features": [ - { - "geometry": { - "coordinates": [ - [ - [ - 25.038330550685124, - 60.29497921873359 - ], - [ - 25.037180473305117, - 60.29487636307983 - ], - [ - 25.036827917541686, - 60.29429636983492 - ], - [ - 25.035635781656623, - 60.294509434771044 - ], - [ - 25.0346088916754, - 60.29254891106162 - ], - [ - 25.040769788807683, - 60.291589437497 - ], - [ - 25.04022566806205, - 60.29036550511179 - ], - [ - 25.042305325413064, - 60.290149463457354 - ], - [ - 25.043072950077036, - 60.29013333869665 - ], - [ - 25.04724510980411, - 60.29261738435491 - ], - [ - 25.048514262487213, - 60.29197399505413 - ], - [ - 25.04919074613015, - 60.29229259744652 - ], - [ - 25.047275666908973, - 60.293357603681955 - ], - [ - 25.04674651339548, - 60.29396094884831 - ], - [ - 25.045984538606916, - 60.29556359293879 - ], - [ - 25.0410192803547, - 60.29602971167706 - ], - [ - 25.039211949420743, - 60.296491443248684 - ], - [ - 25.038330550685124, - 60.29497921873359 - ] - ] - ], - "type": "Polygon" - }, - "id": 1, - "properties": { - "maksullisu": "2 € / tunti", - "objectid": 1 - }, - "type": "Feature" - }, - { - "geometry": { - "coordinates": [ - [ - [ - 24.850767495413702, - 60.319266692115555 - ], - [ - 24.846091195840064, - 60.317177969192386 - ], - [ - 24.83716953508307, - 60.313142225053056 - ], - [ - 24.83840497595719, - 60.31100208304065 - ], - [ - 24.841280862993386, - 60.311415618492354 - ], - [ - 24.841642847963975, - 60.31254006355655 - ], - [ - 24.84665891940124, - 60.31491286135265 - ], - [ - 24.851120618528427, - 60.31319172563568 - ], - [ - 24.852447081822483, - 60.3140296975698 - ], - [ - 24.856279938251905, - 60.31568335693376 - ], - [ - 24.859057217250353, - 60.31626742568362 - ], - [ - 24.858139399790076, - 60.317817202575384 - ], - [ - 24.85727295941106, - 60.318502833518636 - ], - [ - 24.85597413934648, - 60.31811439861645 - ], - [ - 24.85421957257933, - 60.31897565427159 - ], - [ - 24.853341061488837, - 60.31942031741433 - ], - [ - 24.850767495413702, - 60.319266692115555 - ] - ] - ], - "type": "Polygon" - }, - "id": 2, - "properties": { - "maksullisu": "1 € / tunti", - "objectid": 2 - }, - "type": "Feature" - } - ], - "properties": { - "exceededTransferLimit": false - }, - "type": "FeatureCollection" -} diff --git a/services/tests/data/vantaa_parking_payzones_null_geometry.json b/services/tests/data/vantaa_parking_payzones_null_geometry.json deleted file mode 100644 index 0c3b15e91..000000000 --- a/services/tests/data/vantaa_parking_payzones_null_geometry.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "features": [ - { - "geometry": null, - "id": 3, - "properties": { - "maksullisu": "3 € / tunti", - "objectid": 3 - }, - "type": "Feature" - } - ], - "properties": { - "exceededTransferLimit": false - }, - "type": "FeatureCollection" -} diff --git a/services/tests/test_update_vantaa_parking_areas.py b/services/tests/test_update_vantaa_parking_areas.py index 53da84182..3cb89af28 100644 --- a/services/tests/test_update_vantaa_parking_areas.py +++ b/services/tests/test_update_vantaa_parking_areas.py @@ -1,24 +1,162 @@ -import json -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest -from django.contrib.gis.geos import MultiPolygon +from django.contrib.gis.geos import LineString, MultiLineString, MultiPolygon, Polygon from django.core.management import call_command from munigeo.models import ( AdministrativeDivision, + AdministrativeDivisionGeometry, AdministrativeDivisionType, Municipality, ) +from services.management.commands.update_vantaa_parking_areas import ( + Command, + UnsupportedGeometryError, +) + + +@pytest.fixture +def mock_parking_areas_data(): + """Fixture for standard parking areas with mixed geometry types.""" + return [ + { + "geometry": { + "coordinates": [ + [ + [25.084374753255055, 60.3499294370369], + [25.08408380151095, 60.34990982397221], + [25.084089858850913, 60.34983805624883], + [25.084383300724973, 60.349855193165794], + [25.084374753255055, 60.3499294370369], + ] + ], + "type": "Polygon", + }, + "id": 1, + "properties": { + "aikarajoitus": "30 min", + "aikarajoitus_num": None, + "aluetunnus": None, + "katu": None, + "kaupunginosa": None, + "kiekkopaikka": "Kyllä", + "lisätiedot": None, + "objectid": 1, + "objectid_1": None, + "paikkamäärä": 6, + "paikkamääräap": "6 ap", + "tyyppi": "Lyhytaikainen", + "voimassaoloaika": None, + }, + "type": "Feature", + }, + { + "geometry": { + "coordinates": [ + [25.049797811943435, 60.35260110313705], + [25.049934474444232, 60.35248545784165], + [25.05008239193723, 60.35239648422703], + [25.050167558514346, 60.35234643499014], + [25.05058901570631, 60.352171773300924], + ], + "type": "LineString", + }, + "id": 2, + "properties": { + "aikarajoitus": None, + "aikarajoitus_num": None, + "aluetunnus": None, + "katu": None, + "kaupunginosa": None, + "kiekkopaikka": "Ei", + "lisätiedot": None, + "objectid": 2, + "objectid_1": 2, + "paikkamäärä": 8, + "paikkamääräap": "8 ap", + "tyyppi": "Ei rajoitusta", + "voimassaoloaika": None, + }, + "type": "Feature", + }, + ] -def get_mock_data(geometry=True): - if geometry: - file_path = "services/tests/data/vantaa_parking_areas.json" - else: - file_path = "services/tests/data/vantaa_parking_areas_null_geometry.json" - with open(file_path) as json_file: - contents = json.load(json_file) - return contents.get("features") + +@pytest.fixture +def mock_parking_areas_null_geometry_data(): + """Fixture for parking areas with null geometry.""" + return [ + { + "geometry": None, + "id": 3, + "properties": { + "aikarajoitus": "30 min", + "aikarajoitus_num": None, + "aluetunnus": None, + "katu": None, + "kaupunginosa": None, + "kiekkopaikka": "Kyllä", + "lisätiedot": None, + "objectid": 3, + "objectid_1": None, + "paikkamäärä": 6, + "paikkamääräap": "6 ap", + "tyyppi": "Lyhytaikainen", + "voimassaoloaika": None, + }, + "type": "Feature", + } + ] + + +@pytest.fixture +def mock_multilinestring_data(): + """Fixture for parking areas with MultiLineString geometry.""" + return [ + { + "geometry": { + "coordinates": [ + [ + [25.049797811943435, 60.35260110313705], + [25.049934474444232, 60.35248545784165], + [25.05008239193723, 60.35239648422703], + ], + [ + [25.050167558514346, 60.35234643499014], + [25.05058901570631, 60.352171773300924], + [25.0508, 60.3520], + ], + ], + "type": "MultiLineString", + }, + "id": 1, + "properties": { + "aikarajoitus": None, + "aikarajoitus_num": None, + "aluetunnus": None, + "katu": "Test Street", + "kaupunginosa": None, + "kiekkopaikka": "Ei", + "lisätiedot": None, + "objectid": 1, + "objectid_1": 1, + "paikkamäärä": 15, + "paikkamääräap": "15 ap", + "tyyppi": "Ei rajoitusta", + "voimassaoloaika": None, + }, + "type": "Feature", + } + ] + + +def create_mock_query_result(features): + """Helper function to create a mock query result from feature data. + + Returns the features list directly since the code uses len() and iteration. + """ + return features @pytest.mark.django_db @@ -34,10 +172,12 @@ def get_mock_data(geometry=True): ], ) @patch("restapi.FeatureService") -def test_update_parking_areas(mock_feature_service): +def test_update_parking_areas(mock_feature_service, mock_parking_areas_data): # Mock the FeatureService and its layer and features mock_layer_instance = mock_feature_service.return_value.layer.return_value - mock_layer_instance.query.return_value = get_mock_data() + mock_layer_instance.query.return_value = create_mock_query_result( + mock_parking_areas_data + ) municipality = Municipality.objects.create(id="vantaa", name="Vantaa") division_type = AdministrativeDivisionType.objects.create(type="parking_area") @@ -75,9 +215,11 @@ def test_update_parking_areas(mock_feature_service): ], ) @patch("restapi.FeatureService") -def test_delete_removed_parking_areas(mock_feature_service): +def test_delete_removed_parking_areas(mock_feature_service, mock_parking_areas_data): mock_layer_instance = mock_feature_service.return_value.layer.return_value - mock_layer_instance.query.return_value = get_mock_data() + mock_layer_instance.query.return_value = create_mock_query_result( + mock_parking_areas_data + ) municipality = Municipality.objects.create(id="vantaa", name="Vantaa") division_type = AdministrativeDivisionType.objects.create(type="parking_area") @@ -120,9 +262,13 @@ def test_delete_removed_parking_areas(mock_feature_service): ], ) @patch("restapi.FeatureService") -def test_skip_parking_areas_with_no_geometry(mock_feature_service): +def test_skip_parking_areas_with_no_geometry( + mock_feature_service, mock_parking_areas_null_geometry_data +): mock_layer_instance = mock_feature_service.return_value.layer.return_value - mock_layer_instance.query.return_value = get_mock_data(geometry=False) + mock_layer_instance.query.return_value = create_mock_query_result( + mock_parking_areas_null_geometry_data + ) Municipality.objects.create(id="vantaa", name="Vantaa") AdministrativeDivisionType.objects.create(type="parking_area") @@ -130,3 +276,496 @@ def test_skip_parking_areas_with_no_geometry(mock_feature_service): assert AdministrativeDivision.objects.count() == 0 call_command("update_vantaa_parking_areas") assert AdministrativeDivision.objects.count() == 0 + + +@pytest.mark.django_db +def test_transform_line_to_polygon(): + """Test transformation of LineString to Polygon with buffering.""" + command = Command() + + line = LineString((25.0, 60.0), (25.001, 60.001), srid=4326) + + result = command.transform_line_to_polygon(line) + + assert isinstance(result, (Polygon, MultiPolygon)) + assert result.srid == 4326 + assert result.area > 0 + + +@pytest.mark.django_db +def test_get_multi_geom_polygon(): + """Test get_multi_geom with a Polygon.""" + command = Command() + + polygon = Polygon(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0)), srid=4326) + + result = command.get_multi_geom(polygon) + + assert isinstance(result, MultiPolygon) + assert result.srid == 4326 + assert len(result) == 1 + + +@pytest.mark.django_db +def test_get_multi_geom_multipolygon(): + """Test get_multi_geom with a MultiPolygon (should return as-is).""" + command = Command() + + multi_polygon = MultiPolygon( + Polygon(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0))), + Polygon(((2, 2), (2, 3), (3, 3), (3, 2), (2, 2))), + srid=4326, + ) + + result = command.get_multi_geom(multi_polygon) + + assert isinstance(result, MultiPolygon) + assert result.srid == 4326 + assert len(result) == 2 + + +@pytest.mark.django_db +def test_get_multi_geom_linestring(): + """Test get_multi_geom with a LineString (should buffer to MultiPolygon).""" + command = Command() + + line = LineString((25.0, 60.0), (25.001, 60.001), srid=4326) + + result = command.get_multi_geom(line) + + assert isinstance(result, MultiPolygon) + assert result.srid == 4326 + assert result.area > 0 + + +@pytest.mark.django_db +def test_get_multi_geom_multilinestring(): + """Test get_multi_geom with a MultiLineString (should buffer to MultiPolygon).""" + command = Command() + + multi_line = MultiLineString( + LineString((25.0, 60.0), (25.001, 60.001)), + LineString((25.002, 60.002), (25.003, 60.003)), + srid=4326, + ) + + result = command.get_multi_geom(multi_line) + + assert isinstance(result, MultiPolygon) + assert result.srid == 4326 + assert result.area > 0 + + +@pytest.mark.django_db +def test_get_multi_geom_unsupported(): + """Test get_multi_geom with unsupported geometry type.""" + from django.contrib.gis.geos import Point + + command = Command() + + point = Point(25.0, 60.0, srid=4326) + + with pytest.raises(UnsupportedGeometryError) as exc_info: + command.get_multi_geom(point) + + assert "Unsupported geometry type: Point" in str(exc_info.value) + + +@pytest.mark.django_db +@patch( + "services.management.commands.update_vantaa_parking_areas.DATA_SOURCES", + [ + { + "type": "parking_area", + "service_url": "https://url", + "layer_name": "Pysäköintialueet MUOKATTAVA", + "ocd_id_base": "ocd-division/country:fi/kunta:vantaa/pysakointipaikka-alue:", + } + ], +) +@patch("restapi.FeatureService") +def test_pagination_with_object_ids(mock_feature_service): + """Test pagination using object IDs when more than batch size features exist.""" + mock_layer_instance = mock_feature_service.return_value.layer.return_value + + mock_oids_result = MagicMock() + mock_oids_result.objectIds = [1, 2, 3, 4, 5] + + def mock_query(**kwargs): + if kwargs.get("returnIdsOnly"): + return mock_oids_result + elif "objectIds" in kwargs: + oid_string = kwargs["objectIds"] + oids = [int(oid) for oid in oid_string.split(",")] + features = [] + for oid in oids: + features.append( + { + "geometry": { + "coordinates": [ + [ + [25.0, 60.0], + [25.001, 60.0], + [25.001, 60.001], + [25.0, 60.001], + [25.0, 60.0], + ] + ], + "type": "Polygon", + }, + "id": oid, + "properties": {"objectid": oid, "tyyppi": "Lyhytaikainen"}, + "type": "Feature", + } + ) + + mock_result = MagicMock() + mock_result.count = len(features) + mock_result.__iter__ = lambda self: iter(features) + return mock_result + else: + return MagicMock() + + mock_layer_instance.query.side_effect = mock_query + + Municipality.objects.create(id="vantaa", name="Vantaa") + AdministrativeDivisionType.objects.create(type="parking_area") + + call_command("update_vantaa_parking_areas") + + assert AdministrativeDivision.objects.count() == 5 + + +@pytest.mark.django_db +@patch( + "services.management.commands.update_vantaa_parking_areas.DATA_SOURCES", + [ + { + "type": "parking_area", + "service_url": "https://url", + "layer_name": "Pysäköintialueet MUOKATTAVA", + "ocd_id_base": "ocd-division/country:fi/kunta:vantaa/pysakointipaikka-alue:", + } + ], +) +@patch("restapi.FeatureService") +def test_pagination_fallback_no_object_ids( + mock_feature_service, mock_parking_areas_data +): + """Test fallback when object IDs cannot be retrieved.""" + mock_layer_instance = mock_feature_service.return_value.layer.return_value + + def mock_query(**kwargs): + if kwargs.get("returnIdsOnly"): + return {} + else: + return create_mock_query_result(mock_parking_areas_data) + + mock_layer_instance.query.side_effect = mock_query + + Municipality.objects.create(id="vantaa", name="Vantaa") + AdministrativeDivisionType.objects.create(type="parking_area") + + call_command("update_vantaa_parking_areas") + + assert AdministrativeDivision.objects.count() == 2 + + +@pytest.mark.django_db +@patch( + "services.management.commands.update_vantaa_parking_areas.DATA_SOURCES", + [ + { + "type": "parking_area", + "service_url": "https://url", + "layer_name": "Pysäköintialueet MUOKATTAVA", + "ocd_id_base": "ocd-division/country:fi/kunta:vantaa/pysakointipaikka-alue:", + } + ], +) +@patch("restapi.FeatureService") +def test_translations_applied(mock_feature_service, mock_parking_areas_data): + """Test that name translations are applied correctly.""" + mock_layer_instance = mock_feature_service.return_value.layer.return_value + mock_layer_instance.query.return_value = create_mock_query_result( + mock_parking_areas_data + ) + + Municipality.objects.create(id="vantaa", name="Vantaa") + AdministrativeDivisionType.objects.create(type="parking_area") + + call_command("update_vantaa_parking_areas") + + obj = AdministrativeDivision.objects.get(origin_id=1) + + assert obj.name_fi == "Lyhytaikainen" + assert obj.name_en == "Temporary" + assert obj.name_sv == "Kortvarig" + + +@pytest.mark.django_db +@patch( + "services.management.commands.update_vantaa_parking_areas.DATA_SOURCES", + [ + { + "type": "parking_area", + "service_url": "https://url", + "layer_name": "Pysäköintialueet MUOKATTAVA", + "ocd_id_base": "ocd-division/country:fi/kunta:vantaa/pysakointipaikka-alue:", + } + ], +) +@patch("restapi.FeatureService") +def test_skip_parking_areas_without_origin_id(mock_feature_service): + """Test that parking areas without origin_id are skipped.""" + mock_data = MagicMock() + mock_data.count = 1 + mock_data.__iter__ = lambda self: iter( + [ + { + "geometry": { + "coordinates": [ + [ + [25.0, 60.0], + [25.001, 60.0], + [25.001, 60.001], + [25.0, 60.001], + [25.0, 60.0], + ] + ], + "type": "Polygon", + }, + "id": 1, + "properties": {"objectid": None, "tyyppi": "Lyhytaikainen"}, + "type": "Feature", + } + ] + ) + + mock_layer_instance = mock_feature_service.return_value.layer.return_value + mock_layer_instance.query.return_value = mock_data + + Municipality.objects.create(id="vantaa", name="Vantaa") + AdministrativeDivisionType.objects.create(type="parking_area") + + call_command("update_vantaa_parking_areas") + + assert AdministrativeDivision.objects.count() == 0 + + +@pytest.mark.django_db +@patch( + "services.management.commands.update_vantaa_parking_areas.DATA_SOURCES", + [ + { + "type": "street_parking_area", + "service_url": "https://url", + "layer_name": "Kadunvarsipysäköinti MUOKATTAVA", + "ocd_id_base": "ocd-division/country:fi/kunta:vantaa/kadunvarsipysakointi-alue:", + } + ], +) +@patch("restapi.FeatureService") +def test_multiple_data_source_types(mock_feature_service, mock_parking_areas_data): + """Test handling of different data source types.""" + mock_layer_instance = mock_feature_service.return_value.layer.return_value + mock_layer_instance.query.return_value = create_mock_query_result( + mock_parking_areas_data + ) + + Municipality.objects.create(id="vantaa", name="Vantaa") + + call_command("update_vantaa_parking_areas") + + # Should create the division type automatically + assert AdministrativeDivisionType.objects.filter( + type="street_parking_area" + ).exists() + + division_type = AdministrativeDivisionType.objects.get(type="street_parking_area") + assert "Kadunvarsipysäköinti" in division_type.name + + +@pytest.mark.django_db +@patch( + "services.management.commands.update_vantaa_parking_areas.DATA_SOURCES", + [ + { + "type": "parking_area", + "service_url": "https://url", + "layer_name": "Pysäköintialueet MUOKATTAVA", + "ocd_id_base": "ocd-division/country:fi/kunta:vantaa/pysakointipaikka-alue:", + } + ], +) +@patch("restapi.FeatureService") +def test_extra_properties_stored(mock_feature_service, mock_parking_areas_data): + """Test that extra properties from the source are stored.""" + mock_layer_instance = mock_feature_service.return_value.layer.return_value + mock_layer_instance.query.return_value = create_mock_query_result( + mock_parking_areas_data + ) + + Municipality.objects.create(id="vantaa", name="Vantaa") + AdministrativeDivisionType.objects.create(type="parking_area") + + call_command("update_vantaa_parking_areas") + + obj = AdministrativeDivision.objects.get(origin_id=1) + + assert obj.extra is not None + assert obj.extra.get("aikarajoitus") == "30 min" + assert obj.extra.get("paikkamäärä") == 6 + assert obj.extra.get("kiekkopaikka") == "Kyllä" + + +@pytest.mark.django_db +@patch( + "services.management.commands.update_vantaa_parking_areas.DATA_SOURCES", + [ + { + "type": "parking_area", + "service_url": "https://url", + "layer_name": "Pysäköintialueet MUOKATTAVA", + "ocd_id_base": "ocd-division/country:fi/kunta:vantaa/pysakointipaikka-alue:", + } + ], +) +@patch("restapi.FeatureService") +def test_geometry_stored_correctly(mock_feature_service, mock_parking_areas_data): + """Test that geometry is stored in AdministrativeDivisionGeometry.""" + mock_layer_instance = mock_feature_service.return_value.layer.return_value + mock_layer_instance.query.return_value = create_mock_query_result( + mock_parking_areas_data + ) + + Municipality.objects.create(id="vantaa", name="Vantaa") + AdministrativeDivisionType.objects.create(type="parking_area") + + call_command("update_vantaa_parking_areas") + + obj = AdministrativeDivision.objects.get(origin_id=1) + + geom = AdministrativeDivisionGeometry.objects.get(division=obj) + assert geom.boundary is not None + assert isinstance(geom.boundary, MultiPolygon) + + +@pytest.mark.django_db +@patch( + "services.management.commands.update_vantaa_parking_areas.DATA_SOURCES", + [ + { + "type": "parking_area", + "service_url": "https://url", + "layer_name": "Pysäköintialueet MUOKATTAVA", + "ocd_id_base": "ocd-division/country:fi/kunta:vantaa/pysakointipaikka-alue:", + } + ], +) +@patch("restapi.FeatureService") +def test_update_existing_parking_area(mock_feature_service, mock_parking_areas_data): + """Test that existing parking areas are updated, not duplicated.""" + mock_layer_instance = mock_feature_service.return_value.layer.return_value + mock_layer_instance.query.return_value = create_mock_query_result( + mock_parking_areas_data + ) + + municipality = Municipality.objects.create(id="vantaa", name="Vantaa") + division_type = AdministrativeDivisionType.objects.create(type="parking_area") + + # Create an existing parking area with old data + ocd_id = "ocd-division/country:fi/kunta:vantaa/pysakointipaikka-alue:1" + existing_div = AdministrativeDivision.objects.create( + ocd_id=ocd_id, + name_fi="Old Name", + municipality=municipality, + type=division_type, + origin_id="1", + ) + + call_command("update_vantaa_parking_areas") + + # Should still have 2 objects (not 3) + assert AdministrativeDivision.objects.count() == 2 + + # The existing object should be updated + updated_div = AdministrativeDivision.objects.get(pk=existing_div.pk) + assert updated_div.name_fi == "Lyhytaikainen" + assert updated_div.name_en == "Temporary" + + +@pytest.mark.django_db +@patch( + "services.management.commands.update_vantaa_parking_areas.DATA_SOURCES", + [ + { + "type": "parking_area", + "service_url": "https://url", + "layer_name": "Pysäköintialueet MUOKATTAVA", + "ocd_id_base": "ocd-division/country:fi/kunta:vantaa/pysakointipaikka-alue:", + } + ], +) +@patch("restapi.FeatureService") +def test_ocd_id_format(mock_feature_service, mock_parking_areas_data): + """Test that OCD IDs are formatted correctly.""" + mock_layer_instance = mock_feature_service.return_value.layer.return_value + mock_layer_instance.query.return_value = create_mock_query_result( + mock_parking_areas_data + ) + + Municipality.objects.create(id="vantaa", name="Vantaa") + AdministrativeDivisionType.objects.create(type="parking_area") + + call_command("update_vantaa_parking_areas") + + obj = AdministrativeDivision.objects.get(origin_id=1) + + expected_ocd_id = "ocd-division/country:fi/kunta:vantaa/pysakointipaikka-alue:1" + assert obj.ocd_id == expected_ocd_id + + +@pytest.mark.django_db +@patch( + "services.management.commands.update_vantaa_parking_areas.DATA_SOURCES", + [ + { + "type": "hgv_street_parking_area", + "service_url": "https://url", + "layer_name": "Raskaan liikenteen sallitut kadunvarret MUOKATTAVA", + "ocd_id_base": "ocd-division/country:fi/kunta:vantaa/raskaanliikenteen-sallittu-kadunvarsi-alue:", + } + ], +) +@patch("restapi.FeatureService") +def test_multilinestring_geometry_handling( + mock_feature_service, mock_multilinestring_data +): + """Test handling of MultiLineString geometries (should buffer to MultiPolygon).""" + mock_layer_instance = mock_feature_service.return_value.layer.return_value + mock_layer_instance.query.return_value = create_mock_query_result( + mock_multilinestring_data + ) + + Municipality.objects.create(id="vantaa", name="Vantaa") + AdministrativeDivisionType.objects.create(type="hgv_street_parking_area") + + call_command("update_vantaa_parking_areas") + + assert AdministrativeDivision.objects.count() == 1 + + obj = AdministrativeDivision.objects.get(origin_id=1) + + assert type(obj.geometry.boundary) is MultiPolygon + assert obj.geometry.boundary.area > 0 + + +@pytest.mark.django_db +def test_multipolygon_constructor_rejects_multipolygon(): + """Test that Django GEOS MultiPolygon constructor rejects MultiPolygon instances.""" + poly = Polygon(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0))) + multi_poly = MultiPolygon(poly) + + with pytest.raises(TypeError): + MultiPolygon([multi_poly]) diff --git a/services/tests/test_update_vantaa_parking_payzones.py b/services/tests/test_update_vantaa_parking_payzones.py index e4077a879..6d535e855 100644 --- a/services/tests/test_update_vantaa_parking_payzones.py +++ b/services/tests/test_update_vantaa_parking_payzones.py @@ -1,4 +1,3 @@ -import json from unittest.mock import patch import pytest @@ -10,22 +9,91 @@ ) -def get_mock_data(geometry=True): - if geometry: - file_path = "services/tests/data/vantaa_parking_payzones.json" - else: - file_path = "services/tests/data/vantaa_parking_payzones_null_geometry.json" - with open(file_path) as json_file: - contents = json.load(json_file) - return contents.get("features") +@pytest.fixture +def mock_parking_payzones_data(): + """Fixture for standard parking payzones with geometry.""" + return [ + { + "geometry": { + "coordinates": [ + [ + [25.038330550685124, 60.29497921873359], + [25.037180473305117, 60.29487636307983], + [25.036827917541686, 60.29429636983492], + [25.035635781656623, 60.294509434771044], + [25.0346088916754, 60.29254891106162], + [25.040769788807683, 60.291589437497], + [25.04022566806205, 60.29036550511179], + [25.042305325413064, 60.290149463457354], + [25.043072950077036, 60.29013333869665], + [25.04724510980411, 60.29261738435491], + [25.048514262487213, 60.29197399505413], + [25.04919074613015, 60.29229259744652], + [25.047275666908973, 60.293357603681955], + [25.04674651339548, 60.29396094884831], + [25.045984538606916, 60.29556359293879], + [25.0410192803547, 60.29602971167706], + [25.039211949420743, 60.296491443248684], + [25.038330550685124, 60.29497921873359], + ] + ], + "type": "Polygon", + }, + "id": 1, + "properties": {"maksullisu": "2 € / tunti", "objectid": 1}, + "type": "Feature", + }, + { + "geometry": { + "coordinates": [ + [ + [24.850767495413702, 60.319266692115555], + [24.846091195840064, 60.317177969192386], + [24.83716953508307, 60.313142225053056], + [24.83840497595719, 60.31100208304065], + [24.841280862993386, 60.311415618492354], + [24.841642847963975, 60.31254006355655], + [24.84665891940124, 60.31491286135265], + [24.851120618528427, 60.31319172563568], + [24.852447081822483, 60.3140296975698], + [24.856279938251905, 60.31568335693376], + [24.859057217250353, 60.31626742568362], + [24.858139399790076, 60.317817202575384], + [24.85727295941106, 60.318502833518636], + [24.85597413934648, 60.31811439861645], + [24.85421957257933, 60.31897565427159], + [24.853341061488837, 60.31942031741433], + [24.850767495413702, 60.319266692115555], + ] + ], + "type": "Polygon", + }, + "id": 2, + "properties": {"maksullisu": "1 € / tunti", "objectid": 2}, + "type": "Feature", + }, + ] + + +@pytest.fixture +def mock_parking_payzones_null_geometry_data(): + """Fixture for parking payzones with null geometry.""" + return [ + { + "geometry": None, + "id": 3, + "properties": {"maksullisu": "3 € / tunti", "objectid": 3}, + "type": "Feature", + } + ] @pytest.mark.django_db @patch( "services.management.commands.update_vantaa_parking_payzones.Command.get_features" ) -def test_import_parking_payzones(get_features_mock): - get_features_mock.return_value = get_mock_data() +def test_import_parking_payzones(get_features_mock, mock_parking_payzones_data): + get_features_mock.return_value = mock_parking_payzones_data municipality = Municipality.objects.create(id="vantaa", name="Vantaa") division_type = AdministrativeDivisionType.objects.create(type="parking_payzone") @@ -66,8 +134,8 @@ def test_import_parking_payzones(get_features_mock): @patch( "services.management.commands.update_vantaa_parking_payzones.Command.get_features" ) -def test_delete_removed_parking_payzones(get_features_mock): - get_features_mock.return_value = get_mock_data() +def test_delete_removed_parking_payzones(get_features_mock, mock_parking_payzones_data): + get_features_mock.return_value = mock_parking_payzones_data municipality = Municipality.objects.create(id="vantaa", name="Vantaa") division_type = AdministrativeDivisionType.objects.create(type="parking_payzone") call_command("update_vantaa_parking_payzones") @@ -98,8 +166,10 @@ def test_delete_removed_parking_payzones(get_features_mock): @patch( "services.management.commands.update_vantaa_parking_payzones.Command.get_features" ) -def test_skip_parking_payzone_with_no_geometry(get_features_mock): - get_features_mock.return_value = get_mock_data(geometry=False) +def test_skip_parking_payzone_with_no_geometry( + get_features_mock, mock_parking_payzones_null_geometry_data +): + get_features_mock.return_value = mock_parking_payzones_null_geometry_data Municipality.objects.create(id="vantaa", name="Vantaa") AdministrativeDivisionType.objects.create(type="parking_payzone")