From 99d29f7f5d4ec6a46c231ce11acff895afdc4ca3 Mon Sep 17 00:00:00 2001 From: Mika Hietanen Date: Mon, 23 Feb 2026 14:15:59 +0200 Subject: [PATCH 1/3] fix: use 3-char string municipality codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert municipality codes from integers to 3-character strings with leading zeros to ensure proper representation and consistency across the codebase. Changes: - Update MUNICIPALITIES constant to use 3-char string codes (e.g., 91 → "091") - Modify municipality and address import services to convert codes using str(int(code)).zfill(3) - Update all factories and tests to use 3-character string codes consistently Refs: RATYK-163 --- address/api/views.py | 2 +- address/constants.py | 108 +++++++++--------- address/services/address_import.py | 33 +++--- address/services/import_utils.py | 11 +- address/services/municipality_import.py | 6 +- address/tests/factories.py | 2 +- address/tests/test_address_import_service.py | 10 +- address/tests/test_api_views.py | 2 +- .../tests/test_import_postal_codes_command.py | 2 +- .../tests/test_postal_code_import_service.py | 2 +- 10 files changed, 96 insertions(+), 82 deletions(-) diff --git a/address/api/views.py b/address/api/views.py index cf87d70..d66e687 100644 --- a/address/api/views.py +++ b/address/api/views.py @@ -149,7 +149,7 @@ OpenApiParameter( name="municipalitycode", location=OpenApiParameter.QUERY, - description='Municipality code, e.g. "91".', + description='Municipality code, e.g. "091".', required=False, type=str, ), diff --git a/address/constants.py b/address/constants.py index e2d3d67..c0b9ec7 100644 --- a/address/constants.py +++ b/address/constants.py @@ -1,62 +1,62 @@ # Municipalities in the Uusimaa region: https://avoindata.fi/data/dataset/kuntaluettelo # The municipality names are not included in the Digiroad data, so this table is used to -# map each municipality number to the Finnish and Swedish names. +# map each municipality code (3-character string) to the Finnish and Swedish names. MUNICIPALITIES = { "uusimaa": { - 18: ("Askola", "Askola"), - 49: ("Espoo", "Esbo"), - 78: ("Hanko", "Hangö"), - 91: ("Helsinki", "Helsingfors"), - 92: ("Vantaa", "Vanda"), - 106: ("Hyvinkää", "Hyvinge"), - 149: ("Inkoo", "Ingå"), - 186: ("Järvenpää", "Träskända"), - 224: ("Karkkila", "Högfors"), - 235: ("Kauniainen", "Grankulla"), - 245: ("Kerava", "Kervo"), - 257: ("Kirkkonummi", "Kyrkslätt"), - 407: ("Lapinjärvi", "Lappträsk"), - 434: ("Loviisa", "Lovisa"), - 444: ("Lohja", "Lojo"), - 504: ("Myrskylä", "Mörskom"), - 505: ("Mäntsälä", "Mäntsälä"), - 543: ("Nurmijärvi", "Nurmijärvi"), - 611: ("Pornainen", "Borgnäs"), - 616: ("Pukkila", "Pukkila"), - 638: ("Porvoo", "Borgå"), - 710: ("Raasepori", "Raseborg"), - 753: ("Sipoo", "Sibbo"), - 755: ("Siuntio", "Sjundeå"), - 858: ("Tuusula", "Tusby"), - 927: ("Vihti", "Vichtis"), + "018": ("Askola", "Askola"), + "049": ("Espoo", "Esbo"), + "078": ("Hanko", "Hangö"), + "091": ("Helsinki", "Helsingfors"), + "092": ("Vantaa", "Vanda"), + "106": ("Hyvinkää", "Hyvinge"), + "149": ("Inkoo", "Ingå"), + "186": ("Järvenpää", "Träskända"), + "224": ("Karkkila", "Högfors"), + "235": ("Kauniainen", "Grankulla"), + "245": ("Kerava", "Kervo"), + "257": ("Kirkkonummi", "Kyrkslätt"), + "407": ("Lapinjärvi", "Lappträsk"), + "434": ("Loviisa", "Lovisa"), + "444": ("Lohja", "Lojo"), + "504": ("Myrskylä", "Mörskom"), + "505": ("Mäntsälä", "Mäntsälä"), + "543": ("Nurmijärvi", "Nurmijärvi"), + "611": ("Pornainen", "Borgnäs"), + "616": ("Pukkila", "Pukkila"), + "638": ("Porvoo", "Borgå"), + "710": ("Raasepori", "Raseborg"), + "753": ("Sipoo", "Sibbo"), + "755": ("Siuntio", "Sjundeå"), + "858": ("Tuusula", "Tusby"), + "927": ("Vihti", "Vichtis"), }, "varsinais-suomi": { - 19: ("Aura", "Aura"), - 202: ("Kaarina", "S:t Karins"), - 284: ("Koski Tl", "Koskis"), - 304: ("Kustavi", "Gustavs"), - 322: ("Kemiönsaari", "Kimitoön"), - 400: ("Laitila", "Letala"), - 423: ("Lieto", "Lundo"), - 430: ("Loimaa", "Loimaa"), - 445: ("Parainen", "Pargas"), - 480: ("Marttila", "S:t Mårtens"), - 481: ("Masku", "Masko"), - 503: ("Mynämäki", "Virmo"), - 529: ("Naantali", "Nådendal"), - 538: ("Nousiainen", "Nousis"), - 561: ("Oripää", "Oripää"), - 577: ("Paimio", "Pemar"), - 631: ("Pyhäranta", "Pyhäranta"), - 636: ("Pöytyä", "Pöytis"), - 680: ("Raisio", "Reso"), - 704: ("Rusko", "Rusko"), - 734: ("Salo", "Salo"), - 738: ("Sauvo", "Sagu"), - 761: ("Somero", "Somero"), - 833: ("Taivassalo", "Tövsala"), - 853: ("Turku", "Åbo"), - 895: ("Uusikaupunki", "Nystad"), - 918: ("Vehmaa", "Vemo"), + "019": ("Aura", "Aura"), + "202": ("Kaarina", "S:t Karins"), + "284": ("Koski Tl", "Koskis"), + "304": ("Kustavi", "Gustavs"), + "322": ("Kemiönsaari", "Kimitoön"), + "400": ("Laitila", "Letala"), + "423": ("Lieto", "Lundo"), + "430": ("Loimaa", "Loimaa"), + "445": ("Parainen", "Pargas"), + "480": ("Marttila", "S:t Mårtens"), + "481": ("Masku", "Masko"), + "503": ("Mynämäki", "Virmo"), + "529": ("Naantali", "Nådendal"), + "538": ("Nousiainen", "Nousis"), + "561": ("Oripää", "Oripää"), + "577": ("Paimio", "Pemar"), + "631": ("Pyhäranta", "Pyhäranta"), + "636": ("Pöytyä", "Pöytis"), + "680": ("Raisio", "Reso"), + "704": ("Rusko", "Rusko"), + "734": ("Salo", "Salo"), + "738": ("Sauvo", "Sagu"), + "761": ("Somero", "Somero"), + "833": ("Taivassalo", "Tövsala"), + "853": ("Turku", "Åbo"), + "895": ("Uusikaupunki", "Nystad"), + "918": ("Vehmaa", "Vemo"), }, } diff --git a/address/services/address_import.py b/address/services/address_import.py index bea2592..4b4c2fd 100644 --- a/address/services/address_import.py +++ b/address/services/address_import.py @@ -72,21 +72,10 @@ def _build_addresses_from_feature(self, feature: Feature) -> list[Address]: if not self._has_required_fields(feature): return [] - # Create the municipality and street if they don't exist yet - municipality_code = int(value_or_empty(feature, "KUNTAKOODI")) - - # Validate that municipality code exists in the province's municipality list - if municipality_code not in MUNICIPALITIES[self.province]: - # Skip this feature - municipality code not defined for this province + municipality = self._get_municipality(feature) + if municipality is None: return [] - municipality_fi, municipality_sv = MUNICIPALITIES[self.province][ - municipality_code - ] - municipality = create_municipality( - municipality_code, municipality_fi, municipality_sv, municipality_fi - ) - street_name_fi = value_or_empty(feature, "TIENIMI_SU") street_name_sv = value_or_empty(feature, "TIENIMI_RU") or street_name_fi if not street_name_fi: @@ -248,6 +237,24 @@ def _create_street( street.save() return street + def _get_municipality(self, feature: Feature) -> Municipality | None: + """Parse and validate municipality code from feature and return a + Municipality instance, or None if the code is missing or unknown. + """ + raw = value_or_empty(feature, "KUNTAKOODI") + try: + municipality_code = str(int(raw)).zfill(3) + except ValueError: + return None + if municipality_code not in MUNICIPALITIES[self.province]: + return None + municipality_fi, municipality_sv = MUNICIPALITIES[self.province][ + municipality_code + ] + return create_municipality( + municipality_code, municipality_fi, municipality_sv, municipality_fi + ) + def _has_required_fields(self, feature: Feature) -> bool: """Check whether the feature contains street name, first/last numbers and municipality code. diff --git a/address/services/import_utils.py b/address/services/import_utils.py index 58d560d..7d93d67 100644 --- a/address/services/import_utils.py +++ b/address/services/import_utils.py @@ -17,9 +17,16 @@ def value_or_empty(feature: Feature, key: str) -> str: @lru_cache(maxsize=None) # noqa: B019 def create_municipality( - code: int, municipality_fi: str, municipality_sv: str, municipality_en: str + code: str, municipality_fi: str, municipality_sv: str, municipality_en: str ) -> Municipality: - """Create a new municipality if it does not exist already, and return it.""" + """Create a new municipality if it does not exist already, and return it. + + Args: + code: 3-character municipality code (e.g., "091" for Helsinki) + municipality_fi: Finnish name + municipality_sv: Swedish name + municipality_en: English name + """ municipality, _ = Municipality.objects.get_or_create(id=municipality_fi.lower()) municipality.set_current_language("en") municipality.name = municipality_en diff --git a/address/services/municipality_import.py b/address/services/municipality_import.py index f2ab528..e2a0472 100644 --- a/address/services/municipality_import.py +++ b/address/services/municipality_import.py @@ -62,7 +62,7 @@ def import_municipalities(features: Iterable[Feature]) -> int: @staticmethod def _extract_and_validate_fields( feature: Feature, - ) -> tuple[int, str, str] | None: + ) -> tuple[str, str, str] | None: """Extract and validate required municipality fields from feature. Returns: @@ -70,7 +70,7 @@ def _extract_and_validate_fields( None if any validation fails. Required fields: - - NATCODE: Municipality code (must be valid integer) + - NATCODE: Municipality code (must be valid integer, converted to 3-char string) - NAMEFIN or NAMESWE: Municipality name in Finnish or Swedish """ nat_code = value_or_empty(feature, "NATCODE") @@ -80,7 +80,7 @@ def _extract_and_validate_fields( logger.warning("Municipality feature missing NATCODE, skipping") else: try: - code = int(nat_code) + code = str(int(nat_code)).zfill(3) except ValueError: logger.warning(f"Invalid NATCODE value '{nat_code}', skipping") diff --git a/address/tests/factories.py b/address/tests/factories.py index 21784ba..4d8b8a3 100644 --- a/address/tests/factories.py +++ b/address/tests/factories.py @@ -7,7 +7,7 @@ class MunicipalityFactory(DjangoModelFactory): id = LazyAttribute(lambda o: o.name.lower()) name = Faker("city", locale="fi_FI") - code = Faker("random_element", elements=("91", "92", "106"), locale="fi_FI") + code = Faker("random_element", elements=("091", "092", "106"), locale="fi_FI") @post_generation def post(self, *_, **__): diff --git a/address/tests/test_address_import_service.py b/address/tests/test_address_import_service.py index 52ac2b2..ca66cc2 100644 --- a/address/tests/test_address_import_service.py +++ b/address/tests/test_address_import_service.py @@ -11,7 +11,7 @@ from ..tests.factories import AddressFactory, MunicipalityFactory, StreetFactory TEST_FEATURE_FIELDS = { - "KUNTAKOODI": 91, + "KUNTAKOODI": "091", "TIENIMI_SU": "Testikatu", "TIENIMI_RU": "Testgatan", "ENS_TALO_O": 1, @@ -33,7 +33,7 @@ @mark.django_db(transaction=True) def test_delete_address_data_removes_municipalities_streets_and_addresses(): # Create municipality with ID matching one from uusimaa province - municipality = MunicipalityFactory(id="helsinki", code="91") + municipality = MunicipalityFactory(id="helsinki", code="091") street = StreetFactory(municipality=municipality) AddressFactory(municipality=municipality, street=street) AddressImporter(province=TEST_PROVINCE).delete_address_data() @@ -71,7 +71,7 @@ def test_import_addresses_does_nothing_if_street_number_is_missing(): @mark.django_db def test_import_addresses_creates_municipalities(): - feature = _mock_feature({**TEST_FEATURE_FIELDS, "KUNTAKOODI": 49}) + feature = _mock_feature({**TEST_FEATURE_FIELDS, "KUNTAKOODI": "049"}) AddressImporter(TEST_PROVINCE).import_addresses([feature]) assert Municipality.objects.translated(name="Espoo").count() == 1 @@ -79,7 +79,7 @@ def test_import_addresses_creates_municipalities(): @mark.django_db def test_import_addresses_creates_streets(): feature = _mock_feature( - {**TEST_FEATURE_FIELDS, "KUNTAKOODI": 149, "TIENIMI_SU": "CreationTest"} + {**TEST_FEATURE_FIELDS, "KUNTAKOODI": "149", "TIENIMI_SU": "CreationTest"} ) AddressImporter(TEST_PROVINCE).import_addresses([feature]) assert Street.objects.translated(name="CreationTest").count() == 1 @@ -87,7 +87,7 @@ def test_import_addresses_creates_streets(): @mark.django_db def test_import_addresses_creates_addresses(): - feature = _mock_feature({**TEST_FEATURE_FIELDS, "KUNTAKOODI": 186}) + feature = _mock_feature({**TEST_FEATURE_FIELDS, "KUNTAKOODI": "186"}) AddressImporter(TEST_PROVINCE).import_addresses([feature]) street = Street.objects.get() expected_locations = [ diff --git a/address/tests/test_api_views.py b/address/tests/test_api_views.py index a7b0284..b83bdd3 100644 --- a/address/tests/test_api_views.py +++ b/address/tests/test_api_views.py @@ -105,7 +105,7 @@ def test_filter_addresses_by_municipality(api_client: APIClient): @mark.django_db def test_filter_addresses_by_municipality_code(api_client: APIClient): - municipality = MunicipalityFactory(code="91") + municipality = MunicipalityFactory(code="091") match = AddressFactory(municipality=municipality) serializer = AddressSerializer() response = api_client.get( diff --git a/address/tests/test_import_postal_codes_command.py b/address/tests/test_import_postal_codes_command.py index 3ced8c7..f37fe57 100644 --- a/address/tests/test_import_postal_codes_command.py +++ b/address/tests/test_import_postal_codes_command.py @@ -8,7 +8,7 @@ @mark.django_db def test_import_postal_codes_updates_postal_codes_from_shapefile(paavo_shapefile): - municipality = MunicipalityFactory(code="91") + municipality = MunicipalityFactory(code="091") address = AddressFactory( location=Point(x=24.9428, y=60.1666, srid=settings.PROJECTION_SRID), municipality=municipality, diff --git a/address/tests/test_postal_code_import_service.py b/address/tests/test_postal_code_import_service.py index 13206da..eee07d6 100644 --- a/address/tests/test_postal_code_import_service.py +++ b/address/tests/test_postal_code_import_service.py @@ -16,7 +16,7 @@ @mark.django_db(transaction=True) def test_import_postal_codes(): - municipality = Municipality.objects.create(code=91, id="helsinki") + municipality = Municipality.objects.create(code="091", id="helsinki") address = AddressFactory( location=Point(x=24.9428, y=60.1666, srid=settings.PROJECTION_SRID), municipality=municipality, From 85e38a0e3dc776641fd4d9e2f82fda4ad8f0e3f7 Mon Sep 17 00:00:00 2001 From: Mika Hietanen Date: Mon, 23 Feb 2026 14:19:08 +0200 Subject: [PATCH 2/3] feat: add municipality filter parameters to MunicipalityViewSet - Add municipality filter parameters to MunicipalityViewSet with OpenAPI documentation - Add comprehensive tests for municipality filtering by name and code Refs: RATYK-163 --- address/api/views.py | 25 +++++++++++++++++++- address/tests/test_api_views.py | 42 ++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/address/api/views.py b/address/api/views.py index d66e687..9eba58c 100644 --- a/address/api/views.py +++ b/address/api/views.py @@ -349,9 +349,32 @@ def _filter_by_post_office(self, areas: QuerySet) -> QuerySet: @extend_schema_view( - list=extend_schema(parameters=_area_parameters), + list=extend_schema(parameters=_area_parameters + _municipality_parameters), retrieve=extend_schema(parameters=_area_parameters), ) class MunicipalityViewSet(ReadOnlyModelViewSet): queryset = Municipality.objects.order_by("pk").prefetch_related("translations") serializer_class = MunicipalitySerializer + + def get_queryset(self) -> QuerySet: + municipalities = self.queryset + municipalities = self._filter_by_municipality(municipalities) + municipalities = self._filter_by_municipality_code(municipalities) + return municipalities + + def _filter_by_municipality(self, municipalities: QuerySet) -> QuerySet: + municipality = self.request.query_params.get("municipality") + if municipality is None: + return municipalities + municipality_ids = list( + Municipality.objects.filter( + translations__name__iexact=municipality + ).values_list("id", flat=True) + ) + return municipalities.filter(id__in=municipality_ids) + + def _filter_by_municipality_code(self, municipalities: QuerySet) -> QuerySet: + municipality_code = self.request.query_params.get("municipalitycode") + if municipality_code is None: + return municipalities + return municipalities.filter(code=municipality_code) diff --git a/address/tests/test_api_views.py b/address/tests/test_api_views.py index b83bdd3..cc3864d 100644 --- a/address/tests/test_api_views.py +++ b/address/tests/test_api_views.py @@ -4,7 +4,11 @@ from pytest import mark from rest_framework.test import APIClient -from ..api.serializers import AddressSerializer, PostalCodeAreaSerializer +from ..api.serializers import ( + AddressSerializer, + MunicipalitySerializer, + PostalCodeAreaSerializer, +) from ..tests.factories import ( AddressFactory, MunicipalityFactory, @@ -285,3 +289,39 @@ def test_filter_postal_area_codes_by_post_office(api_client: APIClient): "previous": None, "results": [serializer.to_representation(match)], } + + +@mark.django_db +def test_filter_municipalities_by_municipality_name(api_client: APIClient): + MunicipalityFactory(name="Vantaa", code="092") + match = MunicipalityFactory(name="Helsinki", code="091") + serializer = MunicipalitySerializer() + response = api_client.get( + reverse("address:municipality-list"), + {"municipality": match.name}, + ) + assert response.status_code == 200 + assert response.data == { + "count": 1, + "next": None, + "previous": None, + "results": [serializer.to_representation(match)], + } + + +@mark.django_db +def test_filter_municipalities_by_municipality_code(api_client: APIClient): + MunicipalityFactory(name="Vantaa", code="092") + match = MunicipalityFactory(name="Helsinki", code="091") + serializer = MunicipalitySerializer() + response = api_client.get( + reverse("address:municipality-list"), + {"municipalitycode": match.code}, + ) + assert response.status_code == 200 + assert response.data == { + "count": 1, + "next": None, + "previous": None, + "results": [serializer.to_representation(match)], + } From 722e2449829513767a2595aa2f2364e97d797c4f Mon Sep 17 00:00:00 2001 From: Mika Hietanen Date: Mon, 23 Feb 2026 14:41:57 +0200 Subject: [PATCH 3/3] feat!: remove API detail endpoints BREAKING CHANGE: Detail endpoints removed for address, municipality and postal code area resources. Clients must use list endpoints with filters instead of retrieving resources by ID. Remove retrieve/detail endpoints from AddressViewSet, MunicipalityViewSet and PostalCodeAreaViewSet, leaving only the list endpoint available for each resource. Update postal code area translation test to use list endpoint with postalcode filter instead of detail endpoint. Refs: RATYK-158 --- address/api/views.py | 11 +++++------ address/tests/test_postal_code_area_api.py | 13 ++++++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/address/api/views.py b/address/api/views.py index 9eba58c..c915c4e 100644 --- a/address/api/views.py +++ b/address/api/views.py @@ -6,7 +6,8 @@ from django.db.models import Q, QuerySet from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view from rest_framework.exceptions import ParseError -from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.mixins import ListModelMixin +from rest_framework.viewsets import GenericViewSet from address.models import Street @@ -164,7 +165,7 @@ + _municipality_parameters ) ) -class AddressViewSet(ReadOnlyModelViewSet): +class AddressViewSet(ListModelMixin, GenericViewSet): queryset = Address.objects.order_by("pk").prefetch_related( "street__translations", "postal_code_area__translations", @@ -306,9 +307,8 @@ def _filter_by_location(self, addresses: QuerySet) -> QuerySet: @extend_schema_view( list=extend_schema(parameters=_area_parameters + _postal_code_parameters), - retrieve=extend_schema(parameters=_area_parameters), ) -class PostalCodeAreaViewSet(ReadOnlyModelViewSet): +class PostalCodeAreaViewSet(ListModelMixin, GenericViewSet): queryset = PostalCodeArea.objects.order_by("pk").prefetch_related("translations") serializer_class = PostalCodeAreaSerializer @@ -350,9 +350,8 @@ def _filter_by_post_office(self, areas: QuerySet) -> QuerySet: @extend_schema_view( list=extend_schema(parameters=_area_parameters + _municipality_parameters), - retrieve=extend_schema(parameters=_area_parameters), ) -class MunicipalityViewSet(ReadOnlyModelViewSet): +class MunicipalityViewSet(ListModelMixin, GenericViewSet): queryset = Municipality.objects.order_by("pk").prefetch_related("translations") serializer_class = MunicipalitySerializer diff --git a/address/tests/test_postal_code_area_api.py b/address/tests/test_postal_code_area_api.py index 10330d3..f39760c 100644 --- a/address/tests/test_postal_code_area_api.py +++ b/address/tests/test_postal_code_area_api.py @@ -28,21 +28,24 @@ def test_postal_code_area_api_includes_post_office_translations(api_client: APIC area.save() - url = reverse("address:postalcodearea-detail", kwargs={"pk": area.pk}) - response = api_client.get(url) + url = reverse("address:postalcodearea-list") + response = api_client.get(url, {"postalcode": "00900"}) assert response.status_code == 200 data = response.json() - assert data["postal_code"] == "00900" + assert data["count"] == 1 + result = data["results"][0] - assert data["post_office"] == { + assert result["postal_code"] == "00900" + + assert result["post_office"] == { "fi": "HELSINKI", "sv": "HELSINGFORS", "en": "HELSINKI", } - assert data["name"] == { + assert result["name"] == { "fi": "Helsinki Keskusta", "sv": "Helsingfors Centrum", "en": "Helsinki Centre",