From 56d840cd22d1476c34f009be34ae52a988167726 Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Thu, 17 Oct 2024 17:02:54 +0200 Subject: [PATCH 1/3] :sparkles: [#246] Add is_standaard_adres for DigitaalAdres is_standaard_adres=True is unique per soort_digitaal_adres and setting a new default, revokes the other default --- .../api/serializers/digitaal_adres.py | 17 ++++++++++- ...gitaaladres_is_standaard_adres_and_more.py | 30 +++++++++++++++++++ .../klantinteracties/models/digitaal_adres.py | 26 ++++++++++++++++ .../components/klantinteracties/openapi.yaml | 8 +++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/openklant/components/klantinteracties/migrations/0022_digitaaladres_is_standaard_adres_and_more.py diff --git a/src/openklant/components/klantinteracties/api/serializers/digitaal_adres.py b/src/openklant/components/klantinteracties/api/serializers/digitaal_adres.py index 868a7b3d..f45ccca3 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 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. From 33ccbd78eaf62df457a0ddbf4916c7c68f633917 Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Thu, 17 Oct 2024 17:04:19 +0200 Subject: [PATCH 2/3] :white_check_mark: [#246] Add tests for DigitaalAdres.is_standaard_adres --- .../api/tests/test_digitaal_adres.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) 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..347650e8 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,47 @@ 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": "adres", + "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_update_digitaal_adres(self): betrokkene, betrokkene2 = BetrokkeneFactory.create_batch(2) partij, partij2 = PartijFactory.create_batch(2) @@ -226,6 +269,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": "changed", + "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() From 290ae07811524a36305cf8e2ec89d4e9f09fea6d Mon Sep 17 00:00:00 2001 From: Steven Bal Date: Fri, 8 Nov 2024 15:01:02 +0100 Subject: [PATCH 3/3] :construction: changes to add separate adres for betrokkene --- .../admin/betrokkene_adres.py | 18 ++ .../klantinteracties/admin/digitaal_adres.py | 9 +- .../api/filterset/digitaal_adres.py | 7 +- .../api/serializers/betrokkene_adres.py | 111 ++++++++++ .../api/serializers/digitaal_adres.py | 26 --- .../api/serializers/klantcontacten.py | 8 +- .../api/serializers/partijen.py | 10 +- .../api/tests/test_digitaal_adres.py | 4 +- .../klantinteracties/api/validators.py | 12 +- .../api/viewsets/digitaal_adres.py | 20 +- .../migrations/0023_betrokkeneadres.py | 82 +++++++ ...igrate_digitaaladres_to_betrokkeneadres.py | 59 +++++ .../0025_remove_digitaaladres_betrokkene.py | 17 ++ .../klantinteracties/models/digitaal_adres.py | 53 ++++- .../models/tests/test_migrations.py | 201 ++++++++++++++++++ src/openklant/tests/utils/__init__.py | 3 + src/openklant/tests/utils/migrations.py | 49 +++++ 17 files changed, 628 insertions(+), 61 deletions(-) create mode 100644 src/openklant/components/klantinteracties/admin/betrokkene_adres.py create mode 100644 src/openklant/components/klantinteracties/api/serializers/betrokkene_adres.py create mode 100644 src/openklant/components/klantinteracties/migrations/0023_betrokkeneadres.py create mode 100644 src/openklant/components/klantinteracties/migrations/0024_migrate_digitaaladres_to_betrokkeneadres.py create mode 100644 src/openklant/components/klantinteracties/migrations/0025_remove_digitaaladres_betrokkene.py create mode 100644 src/openklant/components/klantinteracties/models/tests/test_migrations.py create mode 100644 src/openklant/tests/utils/__init__.py create mode 100644 src/openklant/tests/utils/migrations.py diff --git a/src/openklant/components/klantinteracties/admin/betrokkene_adres.py b/src/openklant/components/klantinteracties/admin/betrokkene_adres.py new file mode 100644 index 00000000..b6d0ecd1 --- /dev/null +++ b/src/openklant/components/klantinteracties/admin/betrokkene_adres.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from ..models.digitaal_adres import BetrokkeneAdres +from .digitaal_adres import BaseAdresAdminForm + + +class BetrokkeneAdresAdminForm(BaseAdresAdminForm): + class Meta: + model = BetrokkeneAdres + fields = "__all__" + + +@admin.register(BetrokkeneAdres) +class BetrokkeneAdresAdmin(admin.ModelAdmin): + readonly_fields = ("uuid",) + search_fields = ("adres",) + autocomplete_fields = ("betrokkene",) + form = BetrokkeneAdresAdminForm diff --git a/src/openklant/components/klantinteracties/admin/digitaal_adres.py b/src/openklant/components/klantinteracties/admin/digitaal_adres.py index 04516b50..ccd52f97 100644 --- a/src/openklant/components/klantinteracties/admin/digitaal_adres.py +++ b/src/openklant/components/klantinteracties/admin/digitaal_adres.py @@ -5,9 +5,8 @@ from ..models.digitaal_adres import DigitaalAdres -class DigitaalAdresAdminForm(forms.ModelForm): +class BaseAdresAdminForm(forms.ModelForm): class Meta: - model = DigitaalAdres fields = "__all__" def clean_adres(self): @@ -16,6 +15,12 @@ def clean_adres(self): return data["adres"] +class DigitaalAdresAdminForm(BaseAdresAdminForm): + class Meta: + model = DigitaalAdres + fields = "__all__" + + @admin.register(DigitaalAdres) class DigitaalAdresAdmin(admin.ModelAdmin): readonly_fields = ("uuid",) diff --git a/src/openklant/components/klantinteracties/api/filterset/digitaal_adres.py b/src/openklant/components/klantinteracties/api/filterset/digitaal_adres.py index 75afb499..c8e05f46 100644 --- a/src/openklant/components/klantinteracties/api/filterset/digitaal_adres.py +++ b/src/openklant/components/klantinteracties/api/filterset/digitaal_adres.py @@ -10,9 +10,8 @@ from openklant.components.klantinteracties.models.digitaal_adres import DigitaalAdres from openklant.components.utils.filters import ExpandFilter - -class DigitaalAdresDetailFilterSet(FilterSet): - expand = ExpandFilter(serializer_class=DigitaalAdresSerializer) +# class DigitaalAdresDetailFilterSet(FilterSet): +# expand = ExpandFilter(serializer_class=DigitaalAdresSerializer) class DigitaalAdresFilterSet(FilterSet): @@ -74,7 +73,7 @@ class DigitaalAdresFilterSet(FilterSet): ), ) - expand = ExpandFilter(serializer_class=DigitaalAdresSerializer) + # expand = ExpandFilter(serializer_class=DigitaalAdresSerializer) class Meta: model = DigitaalAdres diff --git a/src/openklant/components/klantinteracties/api/serializers/betrokkene_adres.py b/src/openklant/components/klantinteracties/api/serializers/betrokkene_adres.py new file mode 100644 index 00000000..2bbb3836 --- /dev/null +++ b/src/openklant/components/klantinteracties/api/serializers/betrokkene_adres.py @@ -0,0 +1,111 @@ +from django.db import transaction +from django.utils.translation import gettext_lazy as _ + +from rest_framework import serializers + +from openklant.components.klantinteracties.api.serializers.constants import ( + SERIALIZER_PATH, +) +from openklant.components.klantinteracties.api.validators import ( + OptionalEmailValidator, + betrokkene_adres_exists, +) +from openklant.components.klantinteracties.models.digitaal_adres import BetrokkeneAdres +from openklant.components.klantinteracties.models.klantcontacten import Betrokkene +from openklant.utils.serializers import get_field_value + + +class BetrokkeneAdresForeignKeySerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = BetrokkeneAdres + fields = ( + "uuid", + "url", + ) + extra_kwargs = { + "uuid": {"required": True, "validators": [betrokkene_adres_exists]}, + "url": { + "view_name": "klantinteracties:betrokkeneadres-detail", + "lookup_field": "uuid", + "help_text": _( + "De unieke URL van dit betrokkene adres binnen deze API." + ), + }, + } + + +class BetrokkeneAdresSerializer(serializers.HyperlinkedModelSerializer): + from openklant.components.klantinteracties.api.serializers.klantcontacten import ( + BetrokkeneForeignKeySerializer, + ) + + verstrekt_door_betrokkene = BetrokkeneForeignKeySerializer( + required=True, + allow_null=True, + help_text=_( + "Digitaal adres dat een betrokkene bij klantcontact verstrekte voor gebruik bij " + "opvolging van een klantcontact." + ), + source="betrokkene", + ) + + inclusion_serializers = { + # 1 level + "verstrekt_door_betrokkene": f"{SERIALIZER_PATH}.klantcontacten.BetrokkeneSerializer", + # 2 levels + "verstrekt_door_betrokkene.had_klantcontact": f"{SERIALIZER_PATH}.klantcontacten.KlantcontactSerializer", + # 3 levels + "verstrekt_door_betrokkene.had_klantcontact.leidde_tot_interne_taken": f"{SERIALIZER_PATH}" + ".internetaken.InterneTaakSerializer", + } + + class Meta: + model = BetrokkeneAdres + fields = ( + "uuid", + "url", + "verstrekt_door_betrokkene", + "adres", + "soort_digitaal_adres", + "omschrijving", + ) + extra_kwargs = { + "uuid": {"read_only": True}, + "url": { + "view_name": "klantinteracties:betrokkeneadres-detail", + "lookup_field": "uuid", + "help_text": _( + "De unieke URL van dit betrokkene adres binnen deze API." + ), + }, + } + + def validate_adres(self, adres): + """ + Define the validator here, to avoid DRF spectacular marking the format for + `adres` as `email` + """ + soort_digitaal_adres = get_field_value( + self, self.initial_data, "soort_digitaal_adres" + ) + OptionalEmailValidator()(adres, soort_digitaal_adres) + return adres + + @transaction.atomic + def update(self, instance, validated_data): + if "betrokkene" in validated_data: + if betrokkene := validated_data.pop("betrokkene", None): + betrokkene = Betrokkene.objects.get(uuid=str(betrokkene.get("uuid"))) + + validated_data["betrokkene"] = betrokkene + + return super().update(instance, validated_data) + + @transaction.atomic + def create(self, validated_data): + if betrokkene := validated_data.pop("betrokkene", None): + validated_data["betrokkene"] = Betrokkene.objects.get( + uuid=str(betrokkene.get("uuid")) + ) + + return super().create(validated_data) diff --git a/src/openklant/components/klantinteracties/api/serializers/digitaal_adres.py b/src/openklant/components/klantinteracties/api/serializers/digitaal_adres.py index f45ccca3..56807169 100644 --- a/src/openklant/components/klantinteracties/api/serializers/digitaal_adres.py +++ b/src/openklant/components/klantinteracties/api/serializers/digitaal_adres.py @@ -3,9 +3,6 @@ from rest_framework import serializers, validators -from openklant.components.klantinteracties.api.serializers.constants import ( - SERIALIZER_PATH, -) from openklant.components.klantinteracties.api.validators import ( OptionalEmailValidator, digitaal_adres_exists, @@ -34,9 +31,6 @@ class Meta: class DigitaalAdresSerializer(serializers.HyperlinkedModelSerializer): - from openklant.components.klantinteracties.api.serializers.klantcontacten import ( - BetrokkeneForeignKeySerializer, - ) from openklant.components.klantinteracties.api.serializers.partijen import ( PartijForeignKeySerializer, ) @@ -50,32 +44,12 @@ class DigitaalAdresSerializer(serializers.HyperlinkedModelSerializer): ), source="partij", ) - verstrekt_door_betrokkene = BetrokkeneForeignKeySerializer( - required=True, - allow_null=True, - help_text=_( - "Digitaal adres dat een betrokkene bij klantcontact verstrekte voor gebruik bij " - "opvolging van een klantcontact." - ), - source="betrokkene", - ) - - inclusion_serializers = { - # 1 level - "verstrekt_door_betrokkene": f"{SERIALIZER_PATH}.klantcontacten.BetrokkeneSerializer", - # 2 levels - "verstrekt_door_betrokkene.had_klantcontact": f"{SERIALIZER_PATH}.klantcontacten.KlantcontactSerializer", - # 3 levels - "verstrekt_door_betrokkene.had_klantcontact.leidde_tot_interne_taken": f"{SERIALIZER_PATH}" - ".internetaken.InterneTaakSerializer", - } class Meta: model = DigitaalAdres fields = ( "uuid", "url", - "verstrekt_door_betrokkene", "verstrekt_door_partij", "adres", "soort_digitaal_adres", diff --git a/src/openklant/components/klantinteracties/api/serializers/klantcontacten.py b/src/openklant/components/klantinteracties/api/serializers/klantcontacten.py index 94352b37..a28bc06a 100644 --- a/src/openklant/components/klantinteracties/api/serializers/klantcontacten.py +++ b/src/openklant/components/klantinteracties/api/serializers/klantcontacten.py @@ -12,9 +12,6 @@ from openklant.components.klantinteracties.api.serializers.constants import ( SERIALIZER_PATH, ) -from openklant.components.klantinteracties.api.serializers.digitaal_adres import ( - DigitaalAdresForeignKeySerializer, -) from openklant.components.klantinteracties.api.validators import ( FKUniqueTogetherValidator, betrokkene_exists, @@ -129,6 +126,9 @@ class Meta: class BetrokkeneSerializer( NestedGegevensGroepMixin, serializers.HyperlinkedModelSerializer ): + from openklant.components.klantinteracties.api.serializers.betrokkene_adres import ( + BetrokkeneAdresForeignKeySerializer, + ) from openklant.components.klantinteracties.api.serializers.partijen import ( PartijForeignKeySerializer, ) @@ -147,7 +147,7 @@ class BetrokkeneSerializer( ), source="klantcontact", ) - digitale_adressen = DigitaalAdresForeignKeySerializer( + digitale_adressen = BetrokkeneAdresForeignKeySerializer( read_only=True, help_text=_("Digitale adressen van de betrokkene bij klantcontact."), source="digitaaladres_set", diff --git a/src/openklant/components/klantinteracties/api/serializers/partijen.py b/src/openklant/components/klantinteracties/api/serializers/partijen.py index d4c31546..f97b451a 100644 --- a/src/openklant/components/klantinteracties/api/serializers/partijen.py +++ b/src/openklant/components/klantinteracties/api/serializers/partijen.py @@ -18,9 +18,10 @@ from openklant.components.klantinteracties.api.serializers.digitaal_adres import ( DigitaalAdresForeignKeySerializer, ) -from openklant.components.klantinteracties.api.serializers.klantcontacten import ( - BetrokkeneForeignKeySerializer, -) + +# from openklant.components.klantinteracties.api.serializers.klantcontacten import ( +# BetrokkeneForeignKeySerializer, +# ) from openklant.components.klantinteracties.api.validators import ( FKUniqueTogetherValidator, categorie_exists, @@ -418,6 +419,9 @@ def create(self, validated_data): class PartijSerializer(NestedGegevensGroepMixin, PolymorphicSerializer): + from openklant.components.klantinteracties.api.serializers.klantcontacten import ( + BetrokkeneForeignKeySerializer, + ) from openklant.components.klantinteracties.api.serializers.rekeningnummers import ( RekeningnummerForeignKeySerializer, ) 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 347650e8..5272dbee 100644 --- a/src/openklant/components/klantinteracties/api/tests/test_digitaal_adres.py +++ b/src/openklant/components/klantinteracties/api/tests/test_digitaal_adres.py @@ -183,7 +183,7 @@ def test_create_digitaal_adres_is_standaard_adres(self): "verstrektDoorBetrokkene": None, "verstrektDoorPartij": {"uuid": str(partij2.uuid)}, "soortDigitaalAdres": "email", - "adres": "adres", + "adres": "foo@bar.com", "omschrijving": "omschrijving", "isStandaardAdres": True, } @@ -300,7 +300,7 @@ def test_update_digitaal_adres_is_standaard_adres(self): "verstrektDoorPartij": {"uuid": str(partij2.uuid)}, "soortDigitaalAdres": "email", "isStandaardAdres": True, - "adres": "changed", + "adres": "foo@bar.com", "omschrijving": "changed", } diff --git a/src/openklant/components/klantinteracties/api/validators.py b/src/openklant/components/klantinteracties/api/validators.py index 66cdc5b4..8a4d0b1a 100644 --- a/src/openklant/components/klantinteracties/api/validators.py +++ b/src/openklant/components/klantinteracties/api/validators.py @@ -8,7 +8,10 @@ from openklant.components.klantinteracties.constants import SoortDigitaalAdres from openklant.components.klantinteracties.models.actoren import Actor from openklant.components.klantinteracties.models.constants import SoortPartij -from openklant.components.klantinteracties.models.digitaal_adres import DigitaalAdres +from openklant.components.klantinteracties.models.digitaal_adres import ( + BetrokkeneAdres, + DigitaalAdres, +) from openklant.components.klantinteracties.models.internetaken import InterneTaak from openklant.components.klantinteracties.models.klantcontacten import ( Betrokkene, @@ -105,6 +108,13 @@ def digitaal_adres_exists(value): raise serializers.ValidationError(_("DigitaalAdres object bestaat niet.")) +def betrokkene_adres_exists(value): + try: + BetrokkeneAdres.objects.get(uuid=str(value)) + except BetrokkeneAdres.DoesNotExist: + raise serializers.ValidationError(_("BetrokkeneAdres object bestaat niet.")) + + def internetaak_exists(value): try: InterneTaak.objects.get(uuid=str(value)) diff --git a/src/openklant/components/klantinteracties/api/viewsets/digitaal_adres.py b/src/openklant/components/klantinteracties/api/viewsets/digitaal_adres.py index c632ef55..9ca0a7af 100644 --- a/src/openklant/components/klantinteracties/api/viewsets/digitaal_adres.py +++ b/src/openklant/components/klantinteracties/api/viewsets/digitaal_adres.py @@ -2,8 +2,7 @@ from rest_framework import viewsets from vng_api_common.pagination import DynamicPageSizePagination -from openklant.components.klantinteracties.api.filterset.digitaal_adres import ( - DigitaalAdresDetailFilterSet, +from openklant.components.klantinteracties.api.filterset.digitaal_adres import ( # DigitaalAdresDetailFilterSet, DigitaalAdresFilterSet, ) from openklant.components.klantinteracties.api.serializers.digitaal_adres import ( @@ -57,12 +56,13 @@ class DigitaalAdresViewSet(ExpandMixin, viewsets.ModelViewSet): pagination_class = DynamicPageSizePagination authentication_classes = (TokenAuthentication,) permission_classes = (TokenPermissions,) + filterset_class = DigitaalAdresFilterSet - @property - def filterset_class(self): - """ - support expand in the detail endpoint - """ - if self.detail: - return DigitaalAdresDetailFilterSet - return DigitaalAdresFilterSet + # @property + # def filterset_class(self): + # """ + # support expand in the detail endpoint + # """ + # if self.detail: + # return DigitaalAdresDetailFilterSet + # return DigitaalAdresFilterSet diff --git a/src/openklant/components/klantinteracties/migrations/0023_betrokkeneadres.py b/src/openklant/components/klantinteracties/migrations/0023_betrokkeneadres.py new file mode 100644 index 00000000..9a1cf2e7 --- /dev/null +++ b/src/openklant/components/klantinteracties/migrations/0023_betrokkeneadres.py @@ -0,0 +1,82 @@ +# Generated by Django 4.2.15 on 2024-11-08 13:26 + +from django.db import migrations, models +import django.db.models.deletion +import openklant.components.utils.mixins +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("klantinteracties", "0022_digitaaladres_is_standaard_adres_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="BetrokkeneAdres", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + help_text="Unieke (technische) identificatiecode van het digitaal adres.", + unique=True, + ), + ), + ( + "soort_digitaal_adres", + models.CharField( + choices=[ + ("email", "Email"), + ("telefoonnummer", "Telefoonnummer"), + ("overig", "Overig"), + ], + help_text="Typering van het digitale adres die aangeeft via welk(e) kanaal of kanalen met dit adres contact kan worden opgenomen.", + max_length=255, + verbose_name="soort digitaal adres", + ), + ), + ( + "adres", + models.CharField( + help_text="Digitaal adres waarmee een persoon of organisatie bereikt kan worden.", + max_length=80, + verbose_name="adres", + ), + ), + ( + "omschrijving", + models.CharField( + help_text="Omschrijving van het digitaal adres.", + max_length=40, + verbose_name="omschrijving", + ), + ), + ( + "betrokkene", + models.ForeignKey( + blank=True, + help_text="'Digitaal Adres' had 'Betrokkene bij klantcontact'", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="klantinteracties.betrokkene", + verbose_name="betrokkene", + ), + ), + ], + options={ + "verbose_name": "betrokkene adres", + }, + bases=(openklant.components.utils.mixins.APIMixin, models.Model), + ), + ] diff --git a/src/openklant/components/klantinteracties/migrations/0024_migrate_digitaaladres_to_betrokkeneadres.py b/src/openklant/components/klantinteracties/migrations/0024_migrate_digitaaladres_to_betrokkeneadres.py new file mode 100644 index 00000000..9f022df6 --- /dev/null +++ b/src/openklant/components/klantinteracties/migrations/0024_migrate_digitaaladres_to_betrokkeneadres.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.15 on 2024-11-08 11:53 + +from django.db import migrations + + +def migrate_digitaaladres_to_betrokkeneadres(apps, schema_editor): + DigitaalAdres = apps.get_model("klantinteracties", "DigitaalAdres") + BetrokkeneAdres = apps.get_model("klantinteracties", "BetrokkeneAdres") + + to_create = [] + for digitaal_adres in DigitaalAdres.objects.filter(betrokkene__isnull=False): + to_create.append( + BetrokkeneAdres( + uuid=digitaal_adres.uuid, + betrokkene=digitaal_adres.betrokkene, + soort_digitaal_adres=digitaal_adres.soort_digitaal_adres, + adres=digitaal_adres.adres, + omschrijving=digitaal_adres.omschrijving, + ) + ) + digitaal_adres.delete() + + if to_create: + BetrokkeneAdres.objects.bulk_create(to_create) + + +def migrate_betrokkeneadres_to__digitaaladres(apps, schema_editor): + DigitaalAdres = apps.get_model("klantinteracties", "DigitaalAdres") + BetrokkeneAdres = apps.get_model("klantinteracties", "BetrokkeneAdres") + + to_create = [] + for betrokkene_adres in BetrokkeneAdres.objects.all(): + to_create.append( + DigitaalAdres( + uuid=betrokkene_adres.uuid, + betrokkene=betrokkene_adres.betrokkene, + soort_digitaal_adres=betrokkene_adres.soort_digitaal_adres, + adres=betrokkene_adres.adres, + omschrijving=betrokkene_adres.omschrijving, + ) + ) + betrokkene_adres.delete() + + if to_create: + DigitaalAdres.objects.bulk_create(to_create) + + +class Migration(migrations.Migration): + + dependencies = [ + ("klantinteracties", "0023_betrokkeneadres"), + ] + + operations = [ + migrations.RunPython( + migrate_digitaaladres_to_betrokkeneadres, + migrate_betrokkeneadres_to__digitaaladres, + ) + ] diff --git a/src/openklant/components/klantinteracties/migrations/0025_remove_digitaaladres_betrokkene.py b/src/openklant/components/klantinteracties/migrations/0025_remove_digitaaladres_betrokkene.py new file mode 100644 index 00000000..992299a4 --- /dev/null +++ b/src/openklant/components/klantinteracties/migrations/0025_remove_digitaaladres_betrokkene.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.15 on 2024-11-08 13:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("klantinteracties", "0024_migrate_digitaaladres_to_betrokkeneadres"), + ] + + operations = [ + migrations.RemoveField( + model_name="digitaaladres", + name="betrokkene", + ), + ] diff --git a/src/openklant/components/klantinteracties/models/digitaal_adres.py b/src/openklant/components/klantinteracties/models/digitaal_adres.py index 3c8321c4..41abbe05 100644 --- a/src/openklant/components/klantinteracties/models/digitaal_adres.py +++ b/src/openklant/components/klantinteracties/models/digitaal_adres.py @@ -23,14 +23,6 @@ class DigitaalAdres(APIMixin, models.Model): null=True, blank=True, ) - betrokkene = models.ForeignKey( - Betrokkene, - on_delete=models.CASCADE, - verbose_name=_("betrokkene"), - help_text=_("'Digitaal Adres' had 'Betrokkene bij klantcontact'"), - blank=True, - null=True, - ) soort_digitaal_adres = models.CharField( _("soort digitaal adres"), help_text=_( @@ -71,7 +63,7 @@ class Meta: ] def __str__(self): - return f"{self.betrokkene} - {self.adres}" + return f"{self.partij} - {self.adres}" def save(self, *args, **kwargs): if self.is_standaard_adres: @@ -84,3 +76,46 @@ def save(self, *args, **kwargs): ).update(is_standaard_adres=False) super().save(*args, **kwargs) + + +class BetrokkeneAdres(APIMixin, models.Model): + uuid = models.UUIDField( + unique=True, + default=uuid.uuid4, + help_text=_("Unieke (technische) identificatiecode van het digitaal adres."), + ) + betrokkene = models.ForeignKey( + Betrokkene, + on_delete=models.CASCADE, + verbose_name=_("betrokkene"), + help_text=_("'Digitaal Adres' had 'Betrokkene bij klantcontact'"), + blank=True, + null=True, + ) + soort_digitaal_adres = models.CharField( + _("soort digitaal adres"), + help_text=_( + "Typering van het digitale adres die aangeeft via welk(e) kanaal of kanalen " + "met dit adres contact kan worden opgenomen." + ), + max_length=255, + choices=SoortDigitaalAdres.choices, + ) + adres = models.CharField( + _("adres"), + help_text=_( + "Digitaal adres waarmee een persoon of organisatie bereikt kan worden." + ), + max_length=80, + ) + omschrijving = models.CharField( + _("omschrijving"), + help_text=_("Omschrijving van het digitaal adres."), + max_length=40, + ) + + class Meta: + verbose_name = _("betrokkene adres") + + def __str__(self): + return f"{self.betrokkene} - {self.adres}" diff --git a/src/openklant/components/klantinteracties/models/tests/test_migrations.py b/src/openklant/components/klantinteracties/models/tests/test_migrations.py new file mode 100644 index 00000000..1b4afaef --- /dev/null +++ b/src/openklant/components/klantinteracties/models/tests/test_migrations.py @@ -0,0 +1,201 @@ +from openklant.components.klantinteracties.constants import SoortDigitaalAdres +from openklant.components.klantinteracties.models.constants import ( + Klantcontrol, + SoortPartij, +) +from openklant.tests.utils import TestMigrations + + +class MigrateDigitaalAdresToBetrokkeneAdres(TestMigrations): + migrate_from = "0023_betrokkeneadres" + migrate_to = "0024_migrate_digitaaladres_to_betrokkeneadres" + app = "klantinteracties" + + def setUpBeforeMigration(self, apps): + KlantContact = apps.get_model("klantinteracties", "KlantContact") + Betrokkene = apps.get_model("klantinteracties", "Betrokkene") + Partij = apps.get_model("klantinteracties", "Partij") + DigitaalAdres = apps.get_model("klantinteracties", "DigitaalAdres") + + self.klantcontact1 = KlantContact.objects.create( + nummer="123", + kanaal="email", + onderwerp="foo", + taal="nld", + vertrouwelijk=False, + ) + self.klantcontact2 = KlantContact.objects.create( + nummer="456", + kanaal="email", + onderwerp="bar", + taal="nld", + vertrouwelijk=True, + ) + + self.partij1 = Partij.objects.create( + nummer="123", + soort_partij=SoortPartij.persoon, + indicatie_actief=True, + ) + self.partij2 = Partij.objects.create( + nummer="456", + soort_partij=SoortPartij.persoon, + indicatie_actief=True, + ) + + self.betrokkene1 = Betrokkene.objects.create( + partij=self.partij1, + klantcontact=self.klantcontact1, + rol=Klantcontrol.klant, + initiator=True, + ) + self.betrokkene2 = Betrokkene.objects.create( + partij=self.partij2, + klantcontact=self.klantcontact1, + rol=Klantcontrol.klant, + initiator=True, + ) + + DigitaalAdres.objects.create( + partij=self.partij1, + soort_digitaal_adres=SoortDigitaalAdres.email, + adres="foo@bar.nl", + ) + DigitaalAdres.objects.create( + partij=self.partij2, + soort_digitaal_adres=SoortDigitaalAdres.email, + adres="foo@bar.com", + ) + DigitaalAdres.objects.create( + betrokkene=self.betrokkene1, + soort_digitaal_adres=SoortDigitaalAdres.email, + adres="bar@baz.nl", + ) + DigitaalAdres.objects.create( + betrokkene=self.betrokkene2, + soort_digitaal_adres=SoortDigitaalAdres.email, + adres="bar@baz.com", + ) + + def test_autorisatiespecs_created(self): + DigitaalAdres = self.apps.get_model("klantinteracties", "DigitaalAdres") + BetrokkeneAdres = self.apps.get_model("klantinteracties", "BetrokkeneAdres") + + digitale_adressen = DigitaalAdres.objects.all() + betrokkene_adressen = BetrokkeneAdres.objects.all() + + # Two DigitaalAdressen should be removed and replaced with BetrokkeneAdressen + self.assertEqual(digitale_adressen.count(), 2) + self.assertEqual(betrokkene_adressen.count(), 2) + + self.assertEqual(digitale_adressen.first().partij.pk, self.partij1.pk) + self.assertEqual(digitale_adressen.first().betrokkene, None) + self.assertEqual(digitale_adressen.first().adres, "foo@bar.nl") + self.assertEqual(digitale_adressen.last().partij.pk, self.partij2.pk) + self.assertEqual(digitale_adressen.last().betrokkene, None) + self.assertEqual(digitale_adressen.last().adres, "foo@bar.com") + + self.assertEqual(betrokkene_adressen.first().betrokkene.pk, self.betrokkene1.pk) + self.assertEqual(betrokkene_adressen.first().adres, "bar@baz.nl") + self.assertEqual(betrokkene_adressen.last().betrokkene.pk, self.betrokkene2.pk) + self.assertEqual(betrokkene_adressen.last().adres, "bar@baz.com") + + +class MigrateBetrokkeneAdresDigitaalAdres(TestMigrations): + migrate_from = "0024_migrate_digitaaladres_to_betrokkeneadres" + migrate_to = "0023_betrokkeneadres" + app = "klantinteracties" + + def setUpBeforeMigration(self, apps): + KlantContact = apps.get_model("klantinteracties", "KlantContact") + Betrokkene = apps.get_model("klantinteracties", "Betrokkene") + Partij = apps.get_model("klantinteracties", "Partij") + DigitaalAdres = apps.get_model("klantinteracties", "DigitaalAdres") + BetrokkeneAdres = apps.get_model("klantinteracties", "BetrokkeneAdres") + + self.klantcontact1 = KlantContact.objects.create( + nummer="123", + kanaal="email", + onderwerp="foo", + taal="nld", + vertrouwelijk=False, + ) + self.klantcontact2 = KlantContact.objects.create( + nummer="456", + kanaal="email", + onderwerp="bar", + taal="nld", + vertrouwelijk=True, + ) + + self.partij1 = Partij.objects.create( + nummer="123", + soort_partij=SoortPartij.persoon, + indicatie_actief=True, + ) + self.partij2 = Partij.objects.create( + nummer="456", + soort_partij=SoortPartij.persoon, + indicatie_actief=True, + ) + + self.betrokkene1 = Betrokkene.objects.create( + partij=self.partij1, + klantcontact=self.klantcontact1, + rol=Klantcontrol.klant, + initiator=True, + ) + self.betrokkene2 = Betrokkene.objects.create( + partij=self.partij2, + klantcontact=self.klantcontact1, + rol=Klantcontrol.klant, + initiator=True, + ) + + DigitaalAdres.objects.create( + partij=self.partij1, + soort_digitaal_adres=SoortDigitaalAdres.email, + adres="foo@bar.nl", + ) + DigitaalAdres.objects.create( + partij=self.partij2, + soort_digitaal_adres=SoortDigitaalAdres.email, + adres="foo@bar.com", + ) + BetrokkeneAdres.objects.create( + betrokkene=self.betrokkene1, + soort_digitaal_adres=SoortDigitaalAdres.email, + adres="bar@baz.nl", + ) + BetrokkeneAdres.objects.create( + betrokkene=self.betrokkene2, + soort_digitaal_adres=SoortDigitaalAdres.email, + adres="bar@baz.com", + ) + + def test_autorisatiespecs_created(self): + DigitaalAdres = self.apps.get_model("klantinteracties", "DigitaalAdres") + BetrokkeneAdres = self.apps.get_model("klantinteracties", "BetrokkeneAdres") + + digitale_adressen = DigitaalAdres.objects.all() + betrokkene_adressen = BetrokkeneAdres.objects.all() + + # All BetrokkeneAddressen should be migrated back to DigitaalAdressen + self.assertEqual(digitale_adressen.count(), 4) + self.assertEqual(betrokkene_adressen.count(), 0) + + self.assertEqual(digitale_adressen[0].partij.pk, self.partij1.pk) + self.assertEqual(digitale_adressen[0].betrokkene, None) + self.assertEqual(digitale_adressen[0].adres, "foo@bar.nl") + + self.assertEqual(digitale_adressen[1].partij.pk, self.partij2.pk) + self.assertEqual(digitale_adressen[1].betrokkene, None) + self.assertEqual(digitale_adressen[1].adres, "foo@bar.com") + + self.assertEqual(digitale_adressen[2].partij, None) + self.assertEqual(digitale_adressen[2].betrokkene.pk, self.betrokkene1.pk) + self.assertEqual(digitale_adressen[2].adres, "bar@baz.nl") + + self.assertEqual(digitale_adressen[3].partij, None) + self.assertEqual(digitale_adressen[3].betrokkene.pk, self.betrokkene2.pk) + self.assertEqual(digitale_adressen[3].adres, "bar@baz.com") diff --git a/src/openklant/tests/utils/__init__.py b/src/openklant/tests/utils/__init__.py new file mode 100644 index 00000000..fa713cd1 --- /dev/null +++ b/src/openklant/tests/utils/__init__.py @@ -0,0 +1,3 @@ +from .migrations import TestMigrations + +__all__ = ["TestMigrations"] diff --git a/src/openklant/tests/utils/migrations.py b/src/openklant/tests/utils/migrations.py new file mode 100644 index 00000000..29cd8e2b --- /dev/null +++ b/src/openklant/tests/utils/migrations.py @@ -0,0 +1,49 @@ +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TestCase, override_settings + +unset = object() + + +class TestMigrations(TestCase): + """ + Test the effect of applying a migration + Copied from https://github.com/open-formulieren/open-forms/blob/master/src/openforms/utils/tests/test_migrations.py + """ + + app = None + migrate_from = unset + migrate_to = unset + setting_overrides = None + + def setUp(self): + _checks = ( + self.migrate_from is not unset, + self.migrate_to is not unset, + self.app, + ) + assert all(_checks), ( + "TestCase '%s' must define migrate_from, migrate_to and app properties" + % type(self).__name__ + ) + self.migrate_from = [(self.app, self.migrate_from)] + self.migrate_to = [(self.app, self.migrate_to)] + executor = MigrationExecutor(connection) + + # Reverse to the original migration + old_migrate_state = executor.migrate(self.migrate_from) + old_apps = old_migrate_state.apps + + self.setUpBeforeMigration(old_apps) + + # Run the migration to test + overrides = self.setting_overrides or {} + with override_settings(**overrides): + executor = MigrationExecutor(connection) + executor.loader.build_graph() # reload. + executor.migrate(self.migrate_to) + + self.apps = executor.loader.project_state(self.migrate_to).apps + + def setUpBeforeMigration(self, apps): + pass