diff --git a/address/api/views.py b/address/api/views.py index cf87d70..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 @@ -149,7 +150,7 @@ OpenApiParameter( name="municipalitycode", location=OpenApiParameter.QUERY, - description='Municipality code, e.g. "91".', + description='Municipality code, e.g. "091".', required=False, type=str, ), @@ -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 @@ -349,9 +349,31 @@ def _filter_by_post_office(self, areas: QuerySet) -> QuerySet: @extend_schema_view( - list=extend_schema(parameters=_area_parameters), - retrieve=extend_schema(parameters=_area_parameters), + list=extend_schema(parameters=_area_parameters + _municipality_parameters), ) -class MunicipalityViewSet(ReadOnlyModelViewSet): +class MunicipalityViewSet(ListModelMixin, GenericViewSet): 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/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..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, @@ -105,7 +109,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( @@ -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)], + } 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_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", 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,