diff --git a/src/open_inwoner/accounts/signals.py b/src/open_inwoner/accounts/signals.py index ad82f577a9..e481c44b6b 100644 --- a/src/open_inwoner/accounts/signals.py +++ b/src/open_inwoner/accounts/signals.py @@ -9,6 +9,8 @@ from open_inwoner.accounts.models import User from open_inwoner.haalcentraal.models import HaalCentraalConfig from open_inwoner.haalcentraal.utils import update_brp_data_in_db +from open_inwoner.kvk.client import KvKClient +from open_inwoner.kvk.signals import company_branch_selected from open_inwoner.openklant.services import OpenKlant2Service, eSuiteKlantenService from open_inwoner.utils.logentry import user_action @@ -26,6 +28,35 @@ } +@receiver(company_branch_selected) +def update_company_from_kvk_api(sender, *args, **kwargs): + request = kwargs["request"] + user = request.user + kvk = user.kvk + vestigingsnummer = kwargs["vestigingsnummer"] + + kvk_client = KvKClient() + + vestiging = kvk_client.get_vestiging(kvk=kvk, vestigingsnummer=vestigingsnummer) + + binnenlandsadres = vestiging.get("adres", {}).get("binnenlandsAdres", {}) + + fields = { + "company_name": vestiging.get("naam"), + "city": binnenlandsadres.get("plaats"), + "postcode": binnenlandsadres.get("postcode"), + "street": binnenlandsadres.get("straatnaam"), + "housenumber": binnenlandsadres.get("huisnummer"), + } + + for field, new_value in fields.items(): + current_value = getattr(user, field, None) + if new_value and new_value != current_value: + setattr(user, field, new_value) + + user.save() + + @receiver(user_logged_in) def update_user_from_klant_on_login(sender, user, request, *args, **kwargs): # This additional guard is mainly to facilitate easier testing, where not diff --git a/src/open_inwoner/kvk/client.py b/src/open_inwoner/kvk/client.py index c1174d5fa7..71eb007657 100644 --- a/src/open_inwoner/kvk/client.py +++ b/src/open_inwoner/kvk/client.py @@ -1,13 +1,15 @@ import logging from functools import cached_property -from typing import cast from urllib.parse import urlencode import requests from requests.exceptions import JSONDecodeError -from .constants import Bedrijf, CompanyType +from open_inwoner.utils.decorators import cache + +from .constants import CompanyType from .models import KvKConfig +from .types import BedrijfValidator logger = logging.getLogger(__name__) @@ -123,10 +125,29 @@ def get_all_company_branches(self, kvk: str, **kwargs) -> list[dict | None]: return branches - def get_company_branch(self, vestigingsnummer: str, **kwargs) -> dict: - kwargs.update({"vestigingsnummer": vestigingsnummer}) - vestiging = self.search(**kwargs).get("resultaten", {}) - return cast(Bedrijf, vestiging) + @cache("kvk:{kvk}vestiging:{vestigingsnummer}") + def get_vestiging(self, kvk: str, vestigingsnummer: str) -> dict: + response = self.search(vestigingsnummer=vestigingsnummer) + + results = response.get("resultaten") + if not results: + logger.error( + "No company branch with vestigingsnummer %s found", vestigingsnummer + ) + return {} + + # the same vestigingsnummer could be associated with different kvk numbers + try: + vestiging = next(r for r in results if r["kvkNummer"] == kvk) + except StopIteration: + logger.error( + "Company branch with vestigingsnummer %s and kvk number %s not found", + vestigingsnummer, + kvk, + ) + return {} + + return BedrijfValidator.validate_python(vestiging) def retrieve_rsin_with_kvk(self, kvk, **kwargs) -> str | None: basisprofiel = self._request( diff --git a/src/open_inwoner/kvk/constants.py b/src/open_inwoner/kvk/constants.py index f3c60a0843..ad158c1707 100644 --- a/src/open_inwoner/kvk/constants.py +++ b/src/open_inwoner/kvk/constants.py @@ -1,5 +1,3 @@ -from typing import Literal, Optional, TypedDict - from django.db import models from django.utils.translation import gettext_lazy as _ @@ -8,40 +6,3 @@ class CompanyType(models.TextChoices): hoofdvestiging = "hoofdvestiging", _("Hoofdvestiging") nevenvestiging = "nevenvestiging", _("Nevenvestiging") rechtspersoon = "rechtspersoon", _("Rechtspersoon") - - -AdresType = Literal["bezoekadres", "postadres"] -BedrijfsType = Literal["hoofdvestiging", "nevenvestiging", "rechtspersoon"] - - -class BedrijfsBinnenlandsAdres(TypedDict, total=False): - type: AdresType - plaats: Optional[str] - postcode: Optional[str] - straatnaam: Optional[str] - huisnummer: Optional[str] - huisletter: Optional[str] - postbusnummer: Optional[str] - - -class BedrijfsBuitenlandsAdres(TypedDict, total=False): - straatHuisnummer: Optional[str] - postcodeWoonplaats: Optional[str] - land: str - - -class BedrijfsAdres(TypedDict, total=False): - binnenlandsAdres: Optional[BedrijfsBinnenlandsAdres] - buitenlandsAdres: Optional[BedrijfsBuitenlandsAdres] - - -class Bedrijf(TypedDict, total=False): - kvkNummer: str - rsin: Optional[str] - vestigingsnummer: Optional[str] - naam: str - adres: BedrijfsAdres - type: BedrijfsType - actief: Optional[str] - vervallenNaam: Optional[str] - _links: Optional[dict] diff --git a/src/open_inwoner/kvk/signals.py b/src/open_inwoner/kvk/signals.py index 6bed4c0aeb..eab7d20c2a 100644 --- a/src/open_inwoner/kvk/signals.py +++ b/src/open_inwoner/kvk/signals.py @@ -1,6 +1,7 @@ import logging from django.contrib.auth.signals import user_logged_in +from django.db.models.signals import Signal from django.dispatch import receiver from django.utils.translation import gettext as _ @@ -34,3 +35,6 @@ def on_kvk_change(sender, user, request, *args, **kwargs): user.rsin = rsin user.is_prepopulated = True user.save() + + +company_branch_selected = Signal() diff --git a/src/open_inwoner/kvk/tests/mocks.py b/src/open_inwoner/kvk/tests/mocks.py index abad4406bf..ea58282c0c 100644 --- a/src/open_inwoner/kvk/tests/mocks.py +++ b/src/open_inwoner/kvk/tests/mocks.py @@ -69,6 +69,57 @@ }, } + +nevenvestigingen = { + "pagina": 1, + "resultatenPerPagina": 10, + "totaal": 2, + "resultaten": [ + { + "kvkNummer": "68750110", + "vestigingsnummer": "000037178601", + "naam": "Test BV Donald Nevenvestiging", + "adres": { + "binnenlandsAdres": { + "type": "bezoekadres", + "straatnaam": "Brinkerinckbaan", + "plaats": "Diepenveen", + } + }, + "type": "nevenvestiging", + "_links": { + "basisprofiel": { + "href": "https://api.kvk.nl/test/api/v1/basisprofielen/68750110" + }, + "vestigingsprofiel": { + "href": "https://api.kvk.nl/test/api/v1/vestigingsprofielen/000037178601" + }, + }, + }, + { + "kvkNummer": "12345678", + "vestigingsnummer": "000037178601", + "naam": "Andere Nevenvestiging", + "adres": { + "binnenlandsAdres": { + "type": "bezoekadres", + "straatnaam": "Brinkerinckbaan", + "plaats": "Fantasieland", + } + }, + "type": "nevenvestiging", + "_links": { + "basisprofiel": { + "href": "https://api.kvk.nl/test/api/v1/basisprofielen/68750110" + }, + "vestigingsprofiel": { + "href": "https://api.kvk.nl/test/api/v1/vestigingsprofielen/000037178601" + }, + }, + }, + ], +} + multiple_branches = { "pagina": 1, "resultatenPerPagina": 10, diff --git a/src/open_inwoner/kvk/tests/test_api.py b/src/open_inwoner/kvk/tests/test_api.py index 0c6281e9fd..fc75f187a4 100644 --- a/src/open_inwoner/kvk/tests/test_api.py +++ b/src/open_inwoner/kvk/tests/test_api.py @@ -1,11 +1,13 @@ +import datetime from unittest.mock import patch from django.test import TestCase import requests_mock +from freezegun import freeze_time from requests.exceptions import InvalidJSONError, SSLError -from ..client import KvKClient +from ..client import KvKClient, logger from ..models import KvKConfig from . import mocks from .factories import CLIENT_CERT, CLIENT_CERT_PAIR, SERVER_CERT @@ -179,6 +181,110 @@ def test_search_all_branches(self, m): ], ) + def test_search_vestiging(self, m): + m.return_value.json.return_value = mocks.nevenvestigingen + + # test caching + with freeze_time("2024-12-13 12:00") as frozen_time: + branch = self.kvk_client.get_vestiging( + kvk="68750110", vestigingsnummer="000037178601" + ) + self.kvk_client.get_vestiging( + kvk="68750110", vestigingsnummer="000037178601" + ) + + m.assert_called_once() + + frozen_time.tick(delta=datetime.timedelta(hours=1)) + + self.kvk_client.get_vestiging( + kvk="68750110", vestigingsnummer="000037178601" + ) + + with self.assertRaises(AssertionError): + m.assert_called_once() + + # test result + self.assertEqual( + branch, + { + "kvkNummer": "68750110", + "vestigingsnummer": "000037178601", + "naam": "Test BV Donald Nevenvestiging", + "adres": { + "binnenlandsAdres": { + "type": "bezoekadres", + "straatnaam": "Brinkerinckbaan", + "plaats": "Diepenveen", + }, + }, + "type": "nevenvestiging", + "_links": { + "basisprofiel": { + "href": "https://api.kvk.nl/test/api/v1/basisprofielen/68750110" + }, + "vestigingsprofiel": { + "href": "https://api.kvk.nl/test/api/v1/vestigingsprofielen/000037178601" + }, + }, + }, + ) + + def test_search_vestiging_cache_invalidation_with_new_kvk(self, m): + m.return_value.json.return_value = mocks.nevenvestigingen + + branch = self.kvk_client.get_vestiging( + kvk="68750110", vestigingsnummer="000037178601" + ) + branch = self.kvk_client.get_vestiging( + kvk="12345678", vestigingsnummer="000037178601" + ) + + with self.assertRaises(AssertionError): + m.assert_called_once() + + self.assertEqual( + branch, + { + "kvkNummer": "12345678", + "vestigingsnummer": "000037178601", + "naam": "Andere Nevenvestiging", + "adres": { + "binnenlandsAdres": { + "type": "bezoekadres", + "straatnaam": "Brinkerinckbaan", + "plaats": "Fantasieland", + } + }, + "type": "nevenvestiging", + "_links": { + "basisprofiel": { + "href": "https://api.kvk.nl/test/api/v1/basisprofielen/68750110" + }, + "vestigingsprofiel": { + "href": "https://api.kvk.nl/test/api/v1/vestigingsprofielen/000037178601" + }, + }, + }, + ) + + def test_search_vestiging_invalid_combination_kvk_vestigingsnummer(self, m): + m.return_value.json.return_value = mocks.nevenvestigingen + + with self.assertLogs(logger.name, level="ERROR") as cm: + branch = self.kvk_client.get_vestiging( + kvk="00000000", vestigingsnummer="000037178601" + ) + + self.assertEqual(len(cm.output), 1) + self.assertEqual( + cm.output[0], + "ERROR:open_inwoner.kvk.client:Company branch with vestigingsnummer " + "000037178601 and kvk number 00000000 not found", + ) + + self.assertEqual(branch, {}) + def test_no_search_without_config(self, m): m.return_value.json.return_value = mocks.multiple_branches diff --git a/src/open_inwoner/kvk/tests/test_views.py b/src/open_inwoner/kvk/tests/test_views.py index 68f215aac5..32f41f54da 100644 --- a/src/open_inwoner/kvk/tests/test_views.py +++ b/src/open_inwoner/kvk/tests/test_views.py @@ -37,11 +37,12 @@ def test_post_branches_page_without_kvk_unauthenticated_throws_401(self): self.assertEqual(response.status_code, 401) @patch("open_inwoner.kvk.client.KvKClient.get_all_company_branches") + @patch("open_inwoner.kvk.client.KvKClient.get_vestiging") @patch( "open_inwoner.kvk.models.KvKConfig.get_solo", ) def test_post_branches_page_with_correct_vestigingsnummer( - self, mock_solo, mock_kvk + self, mock_solo, mock_vestiging, mock_kvk ): mock_kvk_value = { "kvkNummer": "12345678", @@ -67,6 +68,7 @@ def test_post_branches_page_with_correct_vestigingsnummer( }, } mock_kvk.return_value = [mock_kvk_value, mock_kvk_value_vestiging] + mock_vestiging.return_value = mock_kvk_value_vestiging mock_solo.return_value.api_key = "123" mock_solo.return_value.api_root = "http://foo.bar/api/v1/" @@ -89,10 +91,13 @@ def test_post_branches_page_with_correct_vestigingsnummer( self.assertEqual(self.user.street, "Hizzaarderlaan") @patch("open_inwoner.kvk.client.KvKClient.get_all_company_branches") + @patch("open_inwoner.kvk.client.KvKClient.get_vestiging") @patch( "open_inwoner.kvk.models.KvKConfig.get_solo", ) - def test_post_branches_page_with_empty_vestigingsnummer(self, mock_solo, mock_kvk): + def test_post_branches_page_with_empty_vestigingsnummer( + self, mock_solo, mock_get_vestiging, mock_kvk + ): mock_kvk_value = { "kvkNummer": "12345678", "naam": "Test BV Donald", @@ -117,6 +122,7 @@ def test_post_branches_page_with_empty_vestigingsnummer(self, mock_solo, mock_kv }, } mock_kvk.return_value = [mock_kvk_value, mock_kvk_value_vestiging] + mock_get_vestiging.return_value = mock_kvk_value_vestiging mock_solo.return_value.api_key = "123" mock_solo.return_value.api_root = "http://foo.bar/api/v1/" @@ -133,10 +139,10 @@ def test_post_branches_page_with_empty_vestigingsnummer(self, mock_solo, mock_kv # check result of company_branch_selected signal (should only get name) self.user.refresh_from_db() - self.assertEqual(self.user.company_name, "Test BV Donald") - self.assertEqual(self.user.city, "") - self.assertEqual(self.user.postcode, "") - self.assertEqual(self.user.street, "") + self.assertEqual(self.user.company_name, "Test BV Donald Nevenvestiging") + self.assertEqual(self.user.city, "Lollum Dollum") + self.assertEqual(self.user.postcode, "4321") + self.assertEqual(self.user.street, "Hizzaarderlaan") @patch("open_inwoner.kvk.client.KvKClient.get_all_company_branches") @patch( diff --git a/src/open_inwoner/kvk/types.py b/src/open_inwoner/kvk/types.py new file mode 100644 index 0000000000..a4c59e97b1 --- /dev/null +++ b/src/open_inwoner/kvk/types.py @@ -0,0 +1,43 @@ +from typing import Literal, Optional + +from pydantic import TypeAdapter +from typing_extensions import TypedDict + +AdresType = Literal["bezoekadres", "postadres"] +BedrijfsType = Literal["hoofdvestiging", "nevenvestiging", "rechtspersoon"] + + +class BedrijfsBinnenlandsAdres(TypedDict, total=False): + type: AdresType + plaats: Optional[str] + postcode: Optional[str] + straatnaam: Optional[str] + huisnummer: Optional[str] + huisletter: Optional[str] + postbusnummer: Optional[str] + + +class BedrijfsBuitenlandsAdres(TypedDict, total=False): + straatHuisnummer: Optional[str] + postcodeWoonplaats: Optional[str] + land: str + + +class BedrijfsAdres(TypedDict, total=False): + binnenlandsAdres: Optional[BedrijfsBinnenlandsAdres] + buitenlandsAdres: Optional[BedrijfsBuitenlandsAdres] + + +class Bedrijf(TypedDict, total=False): + kvkNummer: str + rsin: Optional[str] + vestigingsnummer: Optional[str] + naam: str + adres: Optional[BedrijfsAdres] + type: BedrijfsType + actief: Optional[str] + vervallenNaam: Optional[str] + _links: Optional[dict] + + +BedrijfValidator = TypeAdapter(Bedrijf) diff --git a/src/open_inwoner/kvk/views.py b/src/open_inwoner/kvk/views.py index 846a3f7589..1372eb3fad 100644 --- a/src/open_inwoner/kvk/views.py +++ b/src/open_inwoner/kvk/views.py @@ -11,6 +11,7 @@ from ..utils.url import get_next_url_from from .client import KvKClient from .forms import CompanyBranchChoiceForm +from .signals import company_branch_selected class CompanyBranchChoiceView(FormView): @@ -92,4 +93,8 @@ def post(self, request): request.session[KVK_BRANCH_SESSION_VARIABLE] = request.POST["branch_number"] + company_branch_selected.send( + sender=self, request=request, vestigingsnummer=request.POST["branch_number"] + ) + return HttpResponseRedirect(redirect)