Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions address/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
),
Expand All @@ -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",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
108 changes: 54 additions & 54 deletions address/constants.py
Original file line number Diff line number Diff line change
@@ -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"),
},
}
33 changes: 20 additions & 13 deletions address/services/address_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions address/services/import_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions address/services/municipality_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ 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:
Tuple of (code, name_fi, name_sv) if all validations pass,
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")
Expand All @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion address/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, *_, **__):
Expand Down
10 changes: 5 additions & 5 deletions address/tests/test_address_import_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -71,23 +71,23 @@ 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


@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


@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 = [
Expand Down
Loading