diff --git a/lemarche/api/emails/tests.py b/lemarche/api/emails/tests.py index 81386f840..216bf9fb5 100644 --- a/lemarche/api/emails/tests.py +++ b/lemarche/api/emails/tests.py @@ -4,15 +4,16 @@ from django.core import mail from django.test import TestCase from django.urls import reverse +from django.utils.text import slugify from lemarche.conversations.factories import ConversationFactory from lemarche.conversations.models import Conversation -class InboundEmailParsingApiTest(TestCase): +class InboundEmailParsingApiTestV0(TestCase): @classmethod def setUpTestData(cls): - cls.conv: Conversation = ConversationFactory() + cls.conv: Conversation = ConversationFactory(version=0) email_data_file_url = os.path.join(os.path.dirname(__file__), "data_inbound_tests.json") with open(email_data_file_url, "r") as file: @@ -22,6 +23,20 @@ def setUpTestData(cls): cls.item_email_data["To"][0]["Address"] = cls.conv.sender_email_buyer_encoded cls.url = reverse("api:inbound-email-parsing") + def test_encoded_items(self): + # test sender and siae encoded format + # self.conv.uuid = "MjpaVwLGJBUwdKpkYJHqNr" + # sender_email_buyer_encoded= 'MjpaVwLGJBUwdKpkYJHqNr_b@reply.staging.lemarche.inclusion.beta.gouv.fr' + sender_encoded = self.conv.sender_email_buyer_encoded.split("@")[0] + self.assertEqual(sender_encoded.split("_")[0], self.conv.uuid) + sender_conv = Conversation.objects.get_conv_from_uuid(conv_uuid=sender_encoded.split("_")[0], version=0) + self.assertEqual(sender_conv, self.conv) + # sender_email_siae_encoded= 'MjpaVwLGJBUwdKpkYJHqNr_b@reply.staging.lemarche.inclusion.beta.gouv.fr' + siae_encoded = self.conv.sender_email_siae_encoded.split("@")[0] + self.assertEqual(siae_encoded.split("_")[0], self.conv.uuid) + siae_conv = Conversation.objects.get_conv_from_uuid(conv_uuid=siae_encoded.split("_")[0], version=0) + self.assertEqual(siae_conv, self.conv) + def test_inbound_serializer_parse_emails(self): response = self.client.post(self.url, data=self.email_data, content_type="application/json") self.assertEqual(response.status_code, 201) @@ -73,3 +88,25 @@ def test_inbound_emails_send_to_siae(self): f"{self.conv.sender_first_name} {self.conv.sender_last_name} <{self.conv.sender_email_buyer_encoded}>", ) self.assertEqual(mail.outbox[0].to, [self.conv.sender_email_siae]) + + +class InboundEmailParsingApiTest(InboundEmailParsingApiTestV0): + # Conversation V1 test + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.conv.version = 1 + cls.conv.save() + + def test_encoded_items(self): + # test sender and siae encoded format + # self.conv.sender_encoded = 'alexandre_delahaye_du_wagner_yves_thomas_3b81' + # self.conv.sender_full_name = "Alexandre Delahaye du Wagner Yves Thomas" + self.assertEqual(self.conv.sender_encoded.split("_")[:-1], slugify(self.conv.sender_full_name).split("-")) + sender_conv = Conversation.objects.get_conv_from_uuid(conv_uuid=self.conv.sender_encoded, version=1) + self.assertEqual(sender_conv, self.conv) + # self.conv.siae_encoded = 'aime_bailly_josephine_vincent_perez_d243' + # self.conv.siae.contact_full_name = "Alexandre Delahaye du Wagner Yves Thomas" + self.assertEqual(self.conv.siae_encoded.split("_")[:-1], slugify(self.conv.siae.contact_full_name).split("-")) + siae_conv = Conversation.objects.get_conv_from_uuid(conv_uuid=self.conv.siae_encoded, version=1) + self.assertEqual(siae_conv, self.conv) diff --git a/lemarche/api/emails/views.py b/lemarche/api/emails/views.py index c4c0ce328..3b2b2f6b8 100644 --- a/lemarche/api/emails/views.py +++ b/lemarche/api/emails/views.py @@ -17,13 +17,14 @@ def post(self, request): serializer = EmailsSerializer(data=request.data) if serializer.is_valid(): inboundEmail = serializer.validated_data.get("items")[0] - address_mail = inboundEmail["To"][0]["Address"] + address_mail_label = inboundEmail["To"][0]["Address"].split("@")[0] # get conversation object - conv_uuid, user_kind = Conversation.get_email_info_from_address(address_mail) - conv: Conversation = Conversation.objects.get(uuid=conv_uuid) + version, conv_uuid, user_kind = Conversation.get_email_info_from_address(address_mail_label) + conv: Conversation = Conversation.objects.get_conv_from_uuid(conv_uuid=conv_uuid, version=version) # save the input data conv.data.append(serializer.data) conv.save() + user_kind = user_kind if version == 0 else conv.get_user_kind(conv_uuid) # make the transfert of emails send_email_from_conversation( conv=conv, diff --git a/lemarche/conversations/admin.py b/lemarche/conversations/admin.py index d89d93551..7af678622 100644 --- a/lemarche/conversations/admin.py +++ b/lemarche/conversations/admin.py @@ -45,7 +45,7 @@ def queryset(self, request, queryset): @admin.register(Conversation, site=admin_site) class ConversationAdmin(admin.ModelAdmin): - list_display = ["id", "uuid", "is_validate", "title", "kind", "answer_count", "created_at"] + list_display = ["id", "uuid", "sender_encoded", "is_validate", "title", "kind", "answer_count", "created_at"] list_filter = ["kind", HasAnswerFilter, IsValidatedFilter] search_fields = ["id", "uuid", "sender_email"] search_help_text = "Cherche sur les champs : ID, UUID, Initiateur (E-mail)" @@ -54,6 +54,7 @@ class ConversationAdmin(admin.ModelAdmin): readonly_fields = [ "id", "uuid", + "sender_encoded", "title", "version", "siae", @@ -69,7 +70,7 @@ class ConversationAdmin(admin.ModelAdmin): fieldsets = ( ( None, - {"fields": ("uuid", "title", "initial_body_message")}, + {"fields": ("uuid", "sender_encoded", "title", "initial_body_message")}, ), ("Interlocuteurs", {"fields": ("sender_first_name", "sender_last_name", "sender_email", "siae")}), ( diff --git a/lemarche/conversations/migrations/0010_conversation_uuid_sender_conversation_uuid_siae_and_more.py b/lemarche/conversations/migrations/0010_conversation_uuid_sender_conversation_uuid_siae_and_more.py new file mode 100644 index 000000000..1e662cb52 --- /dev/null +++ b/lemarche/conversations/migrations/0010_conversation_uuid_sender_conversation_uuid_siae_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.2 on 2023-09-05 18:03 +import uuid + +from django.db import migrations, models + + +def set_uuids(apps, schema_editor): + Conversation = apps.get_model("conversations", "Conversation") + for conversation in Conversation.objects.all(): + siae = conversation.siae + conversation.sender_encoded = ( + f"{conversation.sender_first_name}_{conversation.sender_last_name}_{str(uuid.uuid4())[:4]}" + ) + conversation.siae_encoded = f"{siae.contact_first_name}_{siae.contact_last_name}_{str(uuid.uuid4())[:4]}" + conversation.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("conversations", "0009_conversation_validated_at"), + ] + + operations = [ + migrations.AddField( + model_name="conversation", + name="sender_encoded", + field=models.CharField(db_index=True, default="", max_length=255, verbose_name="Identifiant initiateur"), + preserve_default=False, + ), + migrations.AddField( + model_name="conversation", + name="siae_encoded", + field=models.CharField(db_index=True, default="", max_length=255, verbose_name="Identifiant structure"), + preserve_default=False, + ), + migrations.AlterField( + model_name="conversation", + name="version", + field=models.PositiveIntegerField(default=1, verbose_name="Version"), + ), + migrations.RunPython(set_uuids, migrations.RunPython.noop), + ] diff --git a/lemarche/conversations/migrations/0011_conversation_sender_siae_encoded_unique.py b/lemarche/conversations/migrations/0011_conversation_sender_siae_encoded_unique.py new file mode 100644 index 000000000..56b0931c3 --- /dev/null +++ b/lemarche/conversations/migrations/0011_conversation_sender_siae_encoded_unique.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.2 on 2023-09-05 18:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("conversations", "0010_conversation_uuid_sender_conversation_uuid_siae_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="conversation", + name="sender_encoded", + field=models.CharField(db_index=True, max_length=255, unique=True, verbose_name="Identifiant initiateur"), + ), + migrations.AlterField( + model_name="conversation", + name="siae_encoded", + field=models.CharField(db_index=True, max_length=255, unique=True, verbose_name="Identifiant structure"), + ), + ] diff --git a/lemarche/conversations/models.py b/lemarche/conversations/models.py index e0ede13d2..969ebd7c0 100644 --- a/lemarche/conversations/models.py +++ b/lemarche/conversations/models.py @@ -1,7 +1,10 @@ +from uuid import uuid4 + from django.conf import settings -from django.db import models +from django.db import IntegrityError, models from django.db.models import Func, IntegerField from django.utils import timezone +from django.utils.text import slugify from django_extensions.db.fields import ShortUUIDField from shortuuid import uuid @@ -13,6 +16,19 @@ def has_answer(self): def with_answer_count(self): return self.annotate(answer_count=Func("data", function="jsonb_array_length", output_field=IntegerField())) + def get_conv_from_uuid(self, conv_uuid: str, version=1): + """get conv form + Args: + conv_uuid (str): _description_ + + Returns: + [VERSION, UUID, KIND_SENDER] + """ + if version == 0: + return self.get(uuid=conv_uuid) + else: + return self.get(models.Q(sender_encoded=conv_uuid) | models.Q(siae_encoded=conv_uuid)) + class Conversation(models.Model): KIND_SEARCH = "SEARCH" @@ -34,7 +50,11 @@ class Conversation(models.Model): auto_created=True, ) - version = models.PositiveIntegerField(verbose_name="Version", default=0) + sender_encoded = models.CharField( + verbose_name="Identifiant initiateur", unique=True, db_index=True, max_length=255 + ) + siae_encoded = models.CharField(verbose_name="Identifiant structure", unique=True, db_index=True, max_length=255) + version = models.PositiveIntegerField(verbose_name="Version", default=1) kind = models.CharField( verbose_name="Type de conversation", default=KIND_SEARCH, choices=KIND_CHOICES, max_length=10, db_index=True @@ -70,17 +90,72 @@ class Meta: def __str__(self): return self.title + def set_sender_encoded(self): + """ + The UUID of sender. + """ + if not self.sender_encoded: + slug_sender_full_name = slugify(self.sender_full_name).replace("-", "_") + self.sender_encoded = f"{slug_sender_full_name}_{str(uuid4())[:4]}" + + def set_siae_encoded(self): + """ + The UUID of siae. + """ + if not self.siae_encoded: + siae_slug_full_name = slugify(self.siae.contact_full_name).replace("-", "_") + self.siae_encoded = f"{siae_slug_full_name}_{str(uuid4())[:4]}" + + def save(self, *args, **kwargs): + """ + - generate the uuid field + """ + try: + self.set_sender_encoded() + self.set_siae_encoded() + super().save(*args, **kwargs) + except IntegrityError as e: + # check that it's a new UUID conflict + # Full message expected: duplicate key value violates unique constraint "conversations_conversation_sender_encoded_0f0b821f_uniq" DETAIL: Key (sender_encoded)=(...) already exists. # noqa + if "conversations_conversation_sender_encoded" in str(e): + self.set_sender_encoded() + super().save(*args, **kwargs) + if "conversations_conversation_siae_encoded" in str(e): + self.set_siae_encoded() + super().save(*args, **kwargs) + else: + raise e + + def get_user_kind(self, conv_uuid): + # method only available in version >= 1 + if conv_uuid == self.sender_encoded: + return self.USER_KIND_SENDER_TO_BUYER + elif conv_uuid == self.siae_encoded: + return self.USER_KIND_SENDER_TO_SIAE + @property def sender_email_buyer(self): return self.sender_email + @property + def sender_full_name(self): + return f"{self.sender_first_name} {self.sender_last_name}" + @property def sender_email_buyer_encoded(self): - return f"{self.uuid}_{self.USER_KIND_SENDER_TO_BUYER}@{settings.INBOUND_PARSING_DOMAIN_EMAIL}" + if self.version == 0: + # for legacy + return f"{self.uuid}_{self.USER_KIND_SENDER_TO_BUYER}@{settings.INBOUND_PARSING_DOMAIN_EMAIL}" + if self.version == 1: + return f"{self.sender_encoded}@{settings.INBOUND_PARSING_DOMAIN_EMAIL}" @property def sender_email_siae_encoded(self): - return f"{self.uuid}_{self.USER_KIND_SENDER_TO_SIAE}@{settings.INBOUND_PARSING_DOMAIN_EMAIL}" + if self.version == 0: + # for legacy + return f"{self.uuid}_{self.USER_KIND_SENDER_TO_SIAE}@{settings.INBOUND_PARSING_DOMAIN_EMAIL}" + if self.version == 1: + return f"{self.siae_encoded}@{settings.INBOUND_PARSING_DOMAIN_EMAIL}" @property def sender_email_siae(self): @@ -96,15 +171,22 @@ def nb_messages(self): return len(self.data) + 1 @staticmethod - def get_email_info_from_address(address_mail: str) -> list: + def get_email_info_from_address(address_mail_label: str) -> list: """Extract info from address mail managed by this class Args: - address_mail (str): _description_ + address_mail_label (str): _description_ Returns: - [UUID, KIND_SENDER] + [VERSION, UUID, KIND_SENDER] """ - return address_mail.split("@")[0].split("_") + email_infos = address_mail_label.split("_") + # version is 0 email is like "uuid_kind" + # version is 1 email is like "full_name_can_be_long_short_uuid" + version = 0 if len(email_infos) == 2 else 1 + # in version 1 kind sender is not usefull + uuid = email_infos[0] if version == 0 else "_".join(email_infos) + kind_sender = email_infos[1] if version == 0 else None + return version, uuid, kind_sender @property def is_validated(self) -> bool: