diff --git a/src/openklant/components/klantinteracties/api/serializers/digitaal_adres.py b/src/openklant/components/klantinteracties/api/serializers/digitaal_adres.py index 868a7b3d..d779d94b 100644 --- a/src/openklant/components/klantinteracties/api/serializers/digitaal_adres.py +++ b/src/openklant/components/klantinteracties/api/serializers/digitaal_adres.py @@ -1,7 +1,7 @@ from django.db import transaction from django.utils.translation import gettext_lazy as _ -from rest_framework import serializers +from rest_framework import serializers, validators from openklant.components.klantinteracties.api.serializers.constants import ( SERIALIZER_PATH, @@ -79,6 +79,7 @@ class Meta: "verstrekt_door_partij", "adres", "soort_digitaal_adres", + "is_standaard_adres", "omschrijving", ) extra_kwargs = { @@ -90,6 +91,20 @@ class Meta: }, } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if "soort_digitaal_adres" in self.fields: + # Avoid validating the UniqueConstraint for `soort_digitaal_adres` with + # `is_standaard_adres=True`. We want to enforce the constraint at the database + # level, but not via the API, because setting a new default sets all other + # `is_standaard_adres=False` (via DigitaalAdres.save) + self.fields["soort_digitaal_adres"].validators = [ + validator + for validator in self.fields["soort_digitaal_adres"].validators + if not isinstance(validator, validators.UniqueValidator) + ] + def validate_adres(self, adres): """ Define the validator here, to avoid DRF spectacular marking the format for @@ -101,6 +116,21 @@ def validate_adres(self, adres): OptionalEmailValidator()(adres, soort_digitaal_adres) return adres + def validate(self, attrs): + partij = get_field_value(self, attrs, "partij") + is_standaard_adres = get_field_value(self, attrs, "is_standaard_adres") + if is_standaard_adres and not partij: + raise serializers.ValidationError( + { + "is_standaard_adres": _( + "`is_standaard_adres` kan alleen gezet worden " + "als `verstrekt_door_partij` niet leeg is." + ) + } + ) + + return super().validate(attrs) + @transaction.atomic def update(self, instance, validated_data): if "partij" in validated_data: diff --git a/src/openklant/components/klantinteracties/api/tests/test_digitaal_adres.py b/src/openklant/components/klantinteracties/api/tests/test_digitaal_adres.py index a4fb7925..f6eb1517 100644 --- a/src/openklant/components/klantinteracties/api/tests/test_digitaal_adres.py +++ b/src/openklant/components/klantinteracties/api/tests/test_digitaal_adres.py @@ -5,6 +5,7 @@ from vng_api_common.tests import reverse from openklant.components.klantinteracties.constants import SoortDigitaalAdres +from openklant.components.klantinteracties.models import DigitaalAdres from openklant.components.klantinteracties.models.tests.factories.digitaal_adres import ( DigitaalAdresFactory, ) @@ -73,6 +74,7 @@ def test_create_digitaal_adres(self): self.assertEqual(data["verstrektDoorPartij"], None) self.assertEqual(data["adres"], "foobar@example.com") self.assertEqual(data["omschrijving"], "omschrijving") + self.assertEqual(data["isStandaardAdres"], False) with self.subTest("with_betrokkene_and_partij"): partij = PartijFactory.create() @@ -160,6 +162,82 @@ def test_create_digitaal_adres_email_validation(self): ) self.assertEqual(digitaal_adres.adres, "0612345678") + def test_create_digitaal_adres_is_standaard_adres(self): + """ + Creating a DigitaalAdres with isStandaardAdres=True should make other existing + DigitaalAdressen no longer the default + """ + # Since this has a different Partij, the value of `is_standaard_adres` should stay `True` + partij1, partij2 = PartijFactory.create_batch(2) + existing_adres_different_partij = DigitaalAdresFactory.create( + partij=partij1, is_standaard_adres=True, soort_digitaal_adres="email" + ) + # This adres has the same `soort_digitaal_adres` and `partij`, so the value of + # `is_standaard_adres` should be changed to `False` if we change another one to `True` + existing_adres = DigitaalAdresFactory.create( + is_standaard_adres=True, soort_digitaal_adres="email", partij=partij2 + ) + + list_url = reverse("klantinteracties:digitaaladres-list") + data = { + "verstrektDoorBetrokkene": None, + "verstrektDoorPartij": {"uuid": str(partij2.uuid)}, + "soortDigitaalAdres": "email", + "adres": "foo@bar.com", + "omschrijving": "omschrijving", + "isStandaardAdres": True, + } + + response = self.client.post(list_url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + data = response.json() + + self.assertEqual(data["isStandaardAdres"], True) + + existing_adres_different_partij.refresh_from_db() + existing_adres.refresh_from_db() + new_adres = DigitaalAdres.objects.last() + + self.assertEqual(existing_adres_different_partij.is_standaard_adres, True) + self.assertEqual(existing_adres.is_standaard_adres, False) + self.assertEqual(new_adres.is_standaard_adres, True) + + def test_create_digitaal_adres_is_standaard_adres_without_partij_not_possible(self): + """ + Creating a DigitaalAdres with isStandaardAdres=True should not be possible with + verstrektDoorPartij=None + """ + betrokkene = BetrokkeneFactory.create() + + list_url = reverse("klantinteracties:digitaaladres-list") + data = { + "verstrektDoorBetrokkene": {"uuid": str(betrokkene.uuid)}, + "verstrektDoorPartij": None, + "soortDigitaalAdres": "email", + "adres": "foo@bar.com", + "omschrijving": "omschrijving", + "isStandaardAdres": True, + } + + response = self.client.post(list_url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + data = response.json() + self.assertEqual( + data["invalidParams"], + [ + { + "name": "isStandaardAdres", + "code": "invalid", + "reason": _( + "`is_standaard_adres` kan alleen gezet worden als `verstrekt_door_partij` niet leeg is." + ), + } + ], + ) + self.assertEqual(DigitaalAdres.objects.count(), 0) + def test_update_digitaal_adres(self): betrokkene, betrokkene2 = BetrokkeneFactory.create_batch(2) partij, partij2 = PartijFactory.create_batch(2) @@ -226,6 +304,55 @@ def test_update_digitaal_adres(self): self.assertEqual(data["adres"], "0721434543") self.assertEqual(data["omschrijving"], "changed") + def test_update_digitaal_adres_is_standaard_adres(self): + """ + Creating a DigitaalAdres with isStandaardAdres=True should make other existing + DigitaalAdressen no longer the default + """ + partij1, partij2 = PartijFactory.create_batch(2) + # Since this has a different Partij, the value of `is_standaard_adres` should stay `True` + existing_adres_different_partij = DigitaalAdresFactory.create( + partij=partij1, is_standaard_adres=True, soort_digitaal_adres="email" + ) + # This adres has the same `soort_digitaal_adres` and `partij`, so the value of + # `is_standaard_adres` should be changed to `False` if we change another one to `True` + existing_adres = DigitaalAdresFactory.create( + is_standaard_adres=True, soort_digitaal_adres="email", partij=partij2 + ) + digitaal_adres = DigitaalAdresFactory.create( + partij=partij2, + soort_digitaal_adres="email", + adres="adres", + omschrijving="omschrijving", + ) + detail_url = reverse( + "klantinteracties:digitaaladres-detail", + kwargs={"uuid": str(digitaal_adres.uuid)}, + ) + + data = { + "verstrektDoorBetrokkene": {"uuid": str(digitaal_adres.betrokkene.uuid)}, + "verstrektDoorPartij": {"uuid": str(partij2.uuid)}, + "soortDigitaalAdres": "email", + "isStandaardAdres": True, + "adres": "foo@bar.com", + "omschrijving": "changed", + } + + response = self.client.put(detail_url, data) + + data = response.json() + + self.assertEqual(data["isStandaardAdres"], True) + + existing_adres_different_partij.refresh_from_db() + existing_adres.refresh_from_db() + digitaal_adres.refresh_from_db() + + self.assertEqual(existing_adres_different_partij.is_standaard_adres, True) + self.assertEqual(existing_adres.is_standaard_adres, False) + self.assertEqual(digitaal_adres.is_standaard_adres, True) + def test_partial_update_digitaal_adres(self): betrokkene = BetrokkeneFactory.create() partij = PartijFactory.create() diff --git a/src/openklant/components/klantinteracties/migrations/0022_digitaaladres_is_standaard_adres_and_more.py b/src/openklant/components/klantinteracties/migrations/0022_digitaaladres_is_standaard_adres_and_more.py new file mode 100644 index 00000000..5f07c294 --- /dev/null +++ b/src/openklant/components/klantinteracties/migrations/0022_digitaaladres_is_standaard_adres_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.15 on 2024-10-31 08:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("klantinteracties", "0021_alter_digitaaladres_betrokkene"), + ] + + operations = [ + migrations.AddField( + model_name="digitaaladres", + name="is_standaard_adres", + field=models.BooleanField( + default=False, + help_text="Geeft aan of dit digitaal adres het standaard adres is voor het `soort_digitaal_adres`", + verbose_name="Is standaard adres", + ), + ), + migrations.AddConstraint( + model_name="digitaaladres", + constraint=models.UniqueConstraint( + condition=models.Q(("is_standaard_adres", True)), + fields=("partij", "soort_digitaal_adres"), + name="unique_default_per_partij_and_soort", + ), + ), + ] diff --git a/src/openklant/components/klantinteracties/models/digitaal_adres.py b/src/openklant/components/klantinteracties/models/digitaal_adres.py index 157544af..3c8321c4 100644 --- a/src/openklant/components/klantinteracties/models/digitaal_adres.py +++ b/src/openklant/components/klantinteracties/models/digitaal_adres.py @@ -40,6 +40,13 @@ class DigitaalAdres(APIMixin, models.Model): max_length=255, choices=SoortDigitaalAdres.choices, ) + is_standaard_adres = models.BooleanField( + _("Is standaard adres"), + help_text=_( + "Geeft aan of dit digitaal adres het standaard adres is voor het `soort_digitaal_adres`" + ), + default=False, + ) adres = models.CharField( _("adres"), help_text=_( @@ -55,6 +62,25 @@ class DigitaalAdres(APIMixin, models.Model): class Meta: verbose_name = _("digitaal adres") + constraints = [ + models.UniqueConstraint( + fields=["partij", "soort_digitaal_adres"], + condition=models.Q(is_standaard_adres=True), + name="unique_default_per_partij_and_soort", + ) + ] def __str__(self): return f"{self.betrokkene} - {self.adres}" + + def save(self, *args, **kwargs): + if self.is_standaard_adres: + # Because there can only be one default address per `soort_digitaal_adres` + # and `partij`, mark all other addresses as non-default + DigitaalAdres.objects.filter( + soort_digitaal_adres=self.soort_digitaal_adres, + partij=self.partij, + is_standaard_adres=True, + ).update(is_standaard_adres=False) + + super().save(*args, **kwargs) diff --git a/src/openklant/components/klantinteracties/openapi.yaml b/src/openklant/components/klantinteracties/openapi.yaml index 6575bb24..c9fe60b5 100644 --- a/src/openklant/components/klantinteracties/openapi.yaml +++ b/src/openklant/components/klantinteracties/openapi.yaml @@ -3463,6 +3463,10 @@ components: - $ref: '#/components/schemas/SoortDigitaalAdresEnum' description: Typering van het digitale adres die aangeeft via welk(e) kanaal of kanalen met dit adres contact kan worden opgenomen. + isStandaardAdres: + type: boolean + description: Geeft aan of dit digitaal adres het standaard adres is voor + het `soort_digitaal_adres` omschrijving: type: string description: Omschrijving van het digitaal adres. @@ -4868,6 +4872,10 @@ components: - $ref: '#/components/schemas/SoortDigitaalAdresEnum' description: Typering van het digitale adres die aangeeft via welk(e) kanaal of kanalen met dit adres contact kan worden opgenomen. + isStandaardAdres: + type: boolean + description: Geeft aan of dit digitaal adres het standaard adres is voor + het `soort_digitaal_adres` omschrijving: type: string description: Omschrijving van het digitaal adres.