diff --git a/config/settings/base.py b/config/settings/base.py
index eb22f5e08..8bd30f7c3 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -837,6 +837,12 @@
# ------------------------------------------------------------------------------
INACTIVE_CONVERSATION_TIMEOUT_IN_MONTHS = env.int("INACTIVE_CONVERSATION_TIMEOUT_IN_MONTHS", 6)
+# Privacy timeouts
+# ------------------------------------------------------------------------------
+INACTIVE_USER_TIMEOUT_IN_MONTHS = env.int("INACTIVE_USER_TIMEOUT_IN_MONTHS", 3 * 12)
+INACTIVE_USER_WARNING_DELAY_IN_DAYS = env.int("INACTIVE_USER_WARNING_DELAY_IN_DAYS", 7)
+
+
# Wagtail
# ------------------------------------------------------------------------------
diff --git a/lemarche/conversations/models.py b/lemarche/conversations/models.py
index 282960831..5f030c68a 100644
--- a/lemarche/conversations/models.py
+++ b/lemarche/conversations/models.py
@@ -309,9 +309,6 @@ def get_template_id(self):
return self.brevo_id
return None
- def create_send_log(self, **kwargs):
- TemplateTransactionalSendLog.objects.create(template_transactional=self, **kwargs)
-
def send_transactional_email(
self,
recipient_email,
@@ -325,6 +322,9 @@ def send_transactional_email(
):
if self.is_active:
args = {
+ "template_transactional": self,
+ "recipient_content_object": recipient_content_object,
+ "parent_content_object": parent_content_object,
"template_id": self.get_template_id,
"recipient_email": recipient_email,
"recipient_name": recipient_name,
@@ -337,12 +337,6 @@ def send_transactional_email(
api_mailjet.send_transactional_email_with_template(**args)
elif self.source == conversation_constants.SOURCE_BREVO:
api_brevo.send_transactional_email_with_template(**args)
- # create log
- self.create_send_log(
- recipient_content_object=recipient_content_object,
- parent_content_object=parent_content_object,
- extra_data={"source": self.source, "args": args}, # "response": result()
- )
class TemplateTransactionalSendLog(models.Model):
diff --git a/lemarche/users/admin.py b/lemarche/users/admin.py
index 9f9f71343..4fe1f333c 100644
--- a/lemarche/users/admin.py
+++ b/lemarche/users/admin.py
@@ -6,7 +6,7 @@
from django.contrib.contenttypes.admin import GenericTabularInline
from django.db import models
from django.urls import reverse
-from django.utils.html import format_html, mark_safe
+from django.utils.html import format_html
from fieldsets_with_inlines import FieldsetsInlineMixin
from lemarche.conversations.models import TemplateTransactionalSendLog
@@ -126,6 +126,31 @@ def queryset(self, request, queryset):
return queryset
+class IsAnonymizedFilter(admin.SimpleListFilter):
+ """Custom admin filter to target users who are anonymized"""
+
+ title = "Est anonymisé"
+ parameter_name = "is_anonymized"
+
+ def lookups(self, request, model_admin):
+ return ("Yes", "Oui"), (None, "Non")
+
+ def queryset(self, request, queryset):
+ value = self.value()
+ if value == "Yes":
+ return queryset.filter(is_anonymized=True)
+ return queryset.filter(is_anonymized=False)
+
+ def choices(self, changelist):
+ """Removed the first yield from the base method to only have 2 choices, defaulting too No"""
+ for lookup, title in self.lookup_choices:
+ yield {
+ "selected": self.value() == lookup,
+ "query_string": changelist.get_query_string({self.parameter_name: lookup}),
+ "display": title,
+ }
+
+
class UserNoteInline(GenericTabularInline):
model = Note
fields = ["text", "author", "created_at", "updated_at"]
@@ -181,6 +206,7 @@ class UserAdmin(FieldsetsInlineMixin, UserAdmin):
HasApiKeyFilter,
"is_staff",
"is_superuser",
+ IsAnonymizedFilter,
]
search_fields = ["id", "email", "first_name", "last_name"]
search_help_text = "Cherche sur les champs : ID, E-mail, Prénom, Nom"
@@ -194,8 +220,6 @@ class UserAdmin(FieldsetsInlineMixin, UserAdmin):
"siae_count_annotated_with_link",
"tender_count_annotated_with_link",
"favorite_list_count_with_link",
- "image_url",
- "image_url_display",
"recipient_transactional_send_logs_count_with_link",
"brevo_contact_id",
"extra_data_display",
@@ -263,25 +287,6 @@ class UserAdmin(FieldsetsInlineMixin, UserAdmin):
"Permissions",
{"classes": ["collapse"], "fields": ("is_active", "is_staff", "is_superuser", "groups")},
),
- (
- "Données C4 Cocorico",
- {
- "classes": ["collapse"],
- "fields": (
- "c4_id",
- "c4_website",
- "c4_siret",
- "c4_naf",
- "c4_phone_prefix",
- "c4_time_zone",
- "c4_phone_verified",
- "c4_email_verified",
- "c4_id_card_verified",
- "image_url",
- "image_url_display",
- ),
- },
- ),
(
"Stats",
{
@@ -383,17 +388,6 @@ def favorite_list_count_with_link(self, user):
favorite_list_count_with_link.short_description = "Nombre de listes de favoris"
favorite_list_count_with_link.admin_order_field = "favorite_list_count"
- def image_url_display(self, user):
- if user.image_url:
- return mark_safe(
- f''
- f''
- f""
- )
- return mark_safe("
-
")
-
- image_url_display.short_description = "Image"
-
def recipient_transactional_send_logs_count_with_link(self, obj):
url = reverse("admin:conversations_templatetransactionalsendlog_changelist") + f"?user__id__exact={obj.id}"
return format_html(f'{obj.recipient_transactional_send_logs.count()}')
diff --git a/lemarche/users/management/commands/anonymize_old_users.py b/lemarche/users/management/commands/anonymize_old_users.py
new file mode 100644
index 000000000..90f70bb41
--- /dev/null
+++ b/lemarche/users/management/commands/anonymize_old_users.py
@@ -0,0 +1,114 @@
+from dateutil.relativedelta import relativedelta
+from django.conf import settings
+from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX
+from django.contrib.postgres.functions import RandomUUID
+from django.core.management.base import BaseCommand
+from django.db import transaction
+from django.db.models import F, Value
+from django.db.models.functions import Concat
+from django.template import defaulttags
+from django.utils import timezone
+
+from lemarche.conversations.models import TemplateTransactional
+from lemarche.siaes.models import SiaeUser
+from lemarche.users.models import User
+
+
+class DryRunException(Exception):
+ """To be raised in a dry run mode to abort current transaction"""
+
+ pass
+
+
+class Command(BaseCommand):
+ """Update and anonymize inactive users past a defined inactivity period"""
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--month_timeout",
+ type=int,
+ default=settings.INACTIVE_USER_TIMEOUT_IN_MONTHS,
+ help="Délai en mois à partir duquel les utilisateurs sont considérés inactifs",
+ )
+ parser.add_argument(
+ "--warning_delay",
+ type=int,
+ default=settings.INACTIVE_USER_WARNING_DELAY_IN_DAYS,
+ help="Délai en jours avant la date de suppression pour prevenir les utilisateurs",
+ )
+ parser.add_argument(
+ "--dry_run",
+ type=bool,
+ default=False,
+ help="La commande est exécutée sans que les modifications soient transmises à la base",
+ )
+
+ def handle(self, *args, **options):
+ expiry_date = timezone.now() - relativedelta(months=options["month_timeout"])
+ warning_date = expiry_date + relativedelta(days=options["warning_delay"])
+
+ try:
+ self.anonymize_old_users(expiry_date=expiry_date, dry_run=options["dry_run"])
+ except DryRunException:
+ self.stdout.write("Fin du dry_run d'anonymisation")
+
+ self.warn_users_by_email(expiry_date=expiry_date, warning_date=warning_date, dry_run=options["dry_run"])
+
+ @transaction.atomic
+ def anonymize_old_users(self, expiry_date: timezone.datetime, dry_run: bool):
+ """Update inactive users since x months and strip them from their personal data.
+ email is unique and not nullable, therefore it's replaced with the object id."""
+
+ qs = User.objects.filter(last_login__lte=expiry_date, is_anonymized=False)
+ users_to_update_count = qs.count()
+
+ qs.update(
+ is_active=False, # inactive users are allowed to log in standard login views
+ is_anonymized=True,
+ email=Concat(F("id"), Value("@domain.invalid")),
+ first_name="",
+ last_name="",
+ phone="",
+ api_key=None,
+ api_key_last_updated=None,
+ # https://docs.djangoproject.com/en/5.1/ref/contrib/auth/#django.contrib.auth.models.User.set_unusable_password
+ # Imitating the method but in sql. Prevent password reset attempt
+ # Random string is to avoid chances of impersonation by admins https://code.djangoproject.com/ticket/20079
+ password=Concat(Value(UNUSABLE_PASSWORD_PREFIX), RandomUUID()),
+ )
+ # remove anonymized users in Siaes
+ SiaeUser.objects.filter(user__is_anonymized=True).delete()
+
+ self.stdout.write(f"Utilisateurs anonymisés avec succès ({users_to_update_count} traités)")
+
+ if dry_run: # cancel transaction
+ raise DryRunException
+
+ @transaction.atomic
+ def warn_users_by_email(self, warning_date: timezone.datetime, expiry_date: timezone.datetime, dry_run: bool):
+ email_template = TemplateTransactional.objects.get(code="USER_ANONYMIZATION_WARNING")
+
+ # Users that have already received the mail are excluded
+ users_to_warn = User.objects.filter(last_login__lte=warning_date, is_active=True, is_anonymized=False).exclude(
+ recipient_transactional_send_logs__template_transactional__code=email_template.code,
+ recipient_transactional_send_logs__extra_data__contains={"sent": True},
+ )
+
+ if dry_run:
+ self.stdout.write(
+ f"Fin du dry_run d'avertissement des utilisateurs, {users_to_warn.count()} auraient été avertis"
+ )
+ return # exit before sending emails
+
+ for user in users_to_warn:
+ email_template.send_transactional_email(
+ recipient_email=user.email,
+ recipient_name=user.full_name,
+ variables={
+ "user_full_name": user.full_name,
+ "anonymization_date": defaulttags.date(expiry_date), # natural date
+ },
+ recipient_content_object=user,
+ )
+
+ self.stdout.write(f"Un email d'avertissement a été envoyé à {users_to_warn.count()} utilisateurs")
diff --git a/lemarche/users/migrations/0040_remove_user_c4_email_verified_remove_user_c4_id_and_more.py b/lemarche/users/migrations/0040_remove_user_c4_email_verified_remove_user_c4_id_and_more.py
new file mode 100644
index 000000000..978e745ee
--- /dev/null
+++ b/lemarche/users/migrations/0040_remove_user_c4_email_verified_remove_user_c4_id_and_more.py
@@ -0,0 +1,48 @@
+# Generated by Django 4.2.15 on 2024-12-23 17:02
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0039_remove_user_user_email_ci_uniqueness_and_more"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="user",
+ name="c4_email_verified",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="c4_id",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="c4_id_card_verified",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="c4_naf",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="c4_phone_prefix",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="c4_phone_verified",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="c4_siret",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="c4_time_zone",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="c4_website",
+ ),
+ ]
diff --git a/lemarche/users/migrations/0041_remove_user_image_name_remove_user_image_url.py b/lemarche/users/migrations/0041_remove_user_image_name_remove_user_image_url.py
new file mode 100644
index 000000000..bbc9d223f
--- /dev/null
+++ b/lemarche/users/migrations/0041_remove_user_image_name_remove_user_image_url.py
@@ -0,0 +1,20 @@
+# Generated by Django 4.2.15 on 2024-12-24 09:04
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0040_remove_user_c4_email_verified_remove_user_c4_id_and_more"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="user",
+ name="image_name",
+ ),
+ migrations.RemoveField(
+ model_name="user",
+ name="image_url",
+ ),
+ ]
diff --git a/lemarche/users/migrations/0042_email_template_anonymise_user.py b/lemarche/users/migrations/0042_email_template_anonymise_user.py
new file mode 100644
index 000000000..e0e3ccdce
--- /dev/null
+++ b/lemarche/users/migrations/0042_email_template_anonymise_user.py
@@ -0,0 +1,30 @@
+from django.db import migrations
+
+
+def create_template(apps, schema_editor):
+ TemplateTransactional = apps.get_model("conversations", "TemplateTransactional")
+ TemplateTransactional.objects.create(
+ name="Avertissement d'anonymisation de compte utilisateur",
+ code="USER_ANONYMIZATION_WARNING",
+ description="""
+ Bonjour {{ params.user_full_name }}, votre compte va être supprimé le {{ params.anonymization_date }}
+ si vous ne vous connectez pas avant.
+ Bonne journée,
+ L'équipe du marché de l'inclusion
+ """,
+ )
+
+
+def delete_template(apps, schema_editor):
+ TemplateTransactional = apps.get_model("conversations", "TemplateTransactional")
+ TemplateTransactional.objects.get(code="USER_ANONYMIZATION_WARNING").delete()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0041_remove_user_image_name_remove_user_image_url"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_template, reverse_code=delete_template),
+ ]
diff --git a/lemarche/users/migrations/0043_user_is_anonymized.py b/lemarche/users/migrations/0043_user_is_anonymized.py
new file mode 100644
index 000000000..6b083c0e9
--- /dev/null
+++ b/lemarche/users/migrations/0043_user_is_anonymized.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.15 on 2024-12-26 09:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0042_email_template_anonymise_user"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="is_anonymized",
+ field=models.BooleanField(default=False, verbose_name="L'utilisateur à été anonymisé"),
+ ),
+ ]
diff --git a/lemarche/users/models.py b/lemarche/users/models.py
index 804717f6d..0abd372a8 100644
--- a/lemarche/users/models.py
+++ b/lemarche/users/models.py
@@ -255,19 +255,6 @@ class User(AbstractUser):
default=False,
)
- image_name = models.CharField(verbose_name="Nom de l'image", max_length=255, blank=True)
- image_url = models.URLField(verbose_name="Lien vers l'image", max_length=500, blank=True)
-
- c4_id = models.IntegerField(blank=True, null=True)
- c4_phone_prefix = models.CharField(verbose_name="Indicatif international", max_length=20, blank=True)
- c4_time_zone = models.CharField(verbose_name="Fuseau", max_length=150, blank=True)
- c4_website = models.URLField(verbose_name="Site web", blank=True)
- c4_siret = models.CharField(verbose_name="Siret ou Siren", max_length=14, blank=True)
- c4_naf = models.CharField(verbose_name="Naf", max_length=5, blank=True)
- c4_phone_verified = models.BooleanField(default=False)
- c4_email_verified = models.BooleanField(default=False)
- c4_id_card_verified = models.BooleanField(default=False)
-
# services data
brevo_contact_id = models.PositiveIntegerField("Brevo contact id", blank=True, null=True)
@@ -291,6 +278,7 @@ class User(AbstractUser):
)
# is_active, is_staff, is_superuser
+ is_anonymized = models.BooleanField(verbose_name="L'utilisateur à été anonymisé", default=False)
# date_joined, last_login
created_at = models.DateTimeField(verbose_name="Date de création", default=timezone.now)
diff --git a/lemarche/users/tests.py b/lemarche/users/tests.py
index 335480fdd..d206aafc7 100644
--- a/lemarche/users/tests.py
+++ b/lemarche/users/tests.py
@@ -1,6 +1,17 @@
-from django.test import TestCase
+from datetime import datetime
+from io import StringIO
+from unittest.mock import patch
+
+from dateutil.relativedelta import relativedelta
+from django.core.management import call_command
+from django.core.validators import validate_email
+from django.db.models import F
+from django.test import TestCase, override_settings
+from django.utils import timezone
from lemarche.companies.factories import CompanyFactory
+from lemarche.conversations.constants import SOURCE_BREVO
+from lemarche.conversations.models import TemplateTransactional, TemplateTransactionalSendLog
from lemarche.favorites.factories import FavoriteListFactory
from lemarche.siaes.factories import SiaeFactory
from lemarche.tenders.factories import TenderFactory
@@ -157,3 +168,145 @@ def test_update_related_favorite_list_count_on_save(self):
user.favorite_lists.first().delete()
self.assertEqual(user.favorite_lists.count(), 1)
self.assertEqual(user.favorite_list_count, 1)
+
+
+# To avoid different results when test will be run in the future, we patch
+# and froze timezone.now used in the command
+# Settings are also overriden to avoid changing settings breaking tests
+@patch("django.utils.timezone.now", lambda: datetime(year=2024, month=1, day=1, tzinfo=timezone.utc))
+@override_settings(
+ INACTIVE_USER_TIMEOUT_IN_MONTHS=12,
+ INACTIVE_USER_WARNING_DELAY_IN_DAYS=7,
+)
+class UserAnonymizationTestCase(TestCase):
+ def setUp(self):
+ frozen_now = datetime(year=2024, month=1, day=1, tzinfo=timezone.utc)
+ self.frozen_last_year = frozen_now - relativedelta(years=1)
+ self.frozen_warning_date = self.frozen_last_year + relativedelta(days=7)
+
+ self.std_out = StringIO() # to read output from executed management commands
+
+ siae_1 = SiaeFactory()
+ siae_2 = SiaeFactory()
+
+ UserFactory(first_name="active_user", last_login=frozen_now)
+ UserFactory(
+ last_login=self.frozen_last_year,
+ # personal data
+ email="inactive_user_1@email.com",
+ first_name="inactive_user_1",
+ last_name="doe",
+ phone="06 15 15 15 15",
+ api_key="123456789",
+ api_key_last_updated=frozen_now,
+ siaes=[siae_1],
+ )
+ UserFactory(
+ last_login=self.frozen_last_year,
+ # personal data
+ email="inactive_user_2@email.com",
+ first_name="inactive_user_2",
+ last_name="doe",
+ phone="06 15 15 15 15",
+ api_key="0000000000",
+ api_key_last_updated=frozen_now,
+ siaes=[siae_1, siae_2],
+ )
+ UserFactory(
+ last_login=self.frozen_warning_date,
+ first_name="about_to_be_inactive",
+ )
+ # Set email as active to check if it's really sent
+ TemplateTransactional.objects.all().update(is_active=True, source=SOURCE_BREVO)
+
+ def test_set_inactive_user(self):
+ """Select users that last logged for more than a year and flag them as inactive"""
+ User.objects.filter(last_login__lte=self.frozen_last_year).update(
+ is_active=False, # inactive users should not be allowed to log in
+ email=F("id"),
+ first_name="",
+ last_name="",
+ phone="",
+ )
+ qs = User.objects.filter(last_login__lte=self.frozen_last_year)
+ self.assertQuerySetEqual(qs.order_by("id"), User.objects.filter(is_active=False).order_by("id"))
+
+ anonymized_user = User.objects.filter(is_active=False).first()
+ self.assertEqual(anonymized_user.email, f"{anonymized_user.id}")
+ self.assertFalse(anonymized_user.first_name)
+ self.assertFalse(anonymized_user.last_name)
+ self.assertFalse(anonymized_user.phone)
+
+ # ensure that no error is raised calling save() with a malformed user email
+ anonymized_user.email = "000"
+ anonymized_user.save()
+
+ def test_anonymize_command(self):
+ """Test the admin command 'anonymize_old_users'"""
+
+ call_command("anonymize_old_users", stdout=self.std_out)
+
+ self.assertEqual(User.objects.filter(is_active=False).count(), 2)
+
+ anonymized_user = User.objects.filter(is_active=False).first()
+
+ self.assertEqual(anonymized_user.email, f"{anonymized_user.id}@domain.invalid")
+ validate_email(anonymized_user.email)
+
+ self.assertTrue(anonymized_user.is_anonymized)
+
+ self.assertFalse(anonymized_user.first_name)
+ self.assertFalse(anonymized_user.last_name)
+ self.assertFalse(anonymized_user.phone)
+ self.assertFalse(anonymized_user.siaes.all())
+
+ self.assertIsNone(anonymized_user.api_key)
+ self.assertIsNone(anonymized_user.api_key_last_updated)
+
+ self.assertFalse(anonymized_user.has_usable_password())
+ # from UNUSABLE_PASSWORD_SUFFIX_LENGTH it should be 40, but we're pretty close
+ self.assertEqual(len(anonymized_user.password), 37)
+
+ self.assertIn("Utilisateurs anonymisés avec succès", self.std_out.getvalue())
+
+ def test_warn_command(self):
+ """Test the admin command 'anonymize_old_users' to check if users are warned by email
+ before their account is being removed"""
+
+ call_command("anonymize_old_users", stdout=self.std_out)
+
+ log_qs = TemplateTransactionalSendLog.objects.all()
+ self.assertEqual(
+ log_qs.count(),
+ 1,
+ )
+
+ # Called twice to veryfi that emails are not sent multiple times
+ call_command("anonymize_old_users", stdout=self.std_out)
+ log_qs = TemplateTransactionalSendLog.objects.all()
+ self.assertEqual(
+ log_qs.count(),
+ 1,
+ msg="Warning emails are sent multiple times !",
+ )
+
+ email_log = log_qs.first()
+ self.assertEqual(email_log.recipient_content_object, User.objects.get(first_name="about_to_be_inactive"))
+
+ def test_dryrun_anonymize_command(self):
+ """Ensure that the database is not modified after dryrun"""
+
+ original_qs_count = User.objects.filter(is_active=True).count()
+
+ call_command("anonymize_old_users", dry_run=True, stdout=self.std_out)
+
+ self.assertEqual(original_qs_count, User.objects.filter(is_active=True).count())
+
+ self.assertIn("Utilisateurs anonymisés avec succès (2 traités)", self.std_out.getvalue())
+
+ def test_dryrun_warn_command(self):
+ """Ensure that the database is not modified after dryrun and no email have been sent"""
+
+ call_command("anonymize_old_users", dry_run=True, stdout=self.std_out)
+
+ self.assertFalse(TemplateTransactionalSendLog.objects.all())
diff --git a/lemarche/utils/apis/api_brevo.py b/lemarche/utils/apis/api_brevo.py
index 39c399982..3e62734a2 100644
--- a/lemarche/utils/apis/api_brevo.py
+++ b/lemarche/utils/apis/api_brevo.py
@@ -7,6 +7,7 @@
from huey.contrib.djhuey import task
from sib_api_v3_sdk.rest import ApiException
+from lemarche.conversations.constants import SOURCE_BREVO
from lemarche.tenders import constants as tender_constants
from lemarche.utils.constants import EMAIL_SUBJECT_PREFIX
from lemarche.utils.data import sanitize_to_send_by_email
@@ -348,6 +349,9 @@ def get_all_users_from_list(
@task()
def send_transactional_email_with_template(
+ template_transactional,
+ recipient_content_object,
+ parent_content_object,
template_id: int,
recipient_email: str,
recipient_name: str,
@@ -368,12 +372,37 @@ def send_transactional_email_with_template(
if subject:
data["subject"] = EMAIL_SUBJECT_PREFIX + subject
+ log_args = {
+ "source": SOURCE_BREVO,
+ "args": {
+ "template_id": template_id,
+ "recipient_email": recipient_email,
+ "recipient_name": recipient_name,
+ "variables": variables,
+ "subject": subject,
+ "from_email": from_email,
+ "from_name": from_name,
+ },
+ }
+
+ # create log
+ template_transactional.send_logs.create(
+ recipient_content_object=recipient_content_object,
+ parent_content_object=parent_content_object,
+ extra_data={
+ **log_args,
+ "sent": settings.BITOUBI_ENV in ENV_NOT_ALLOWED, # considered successfully in tests and dev
+ },
+ )
+
if settings.BITOUBI_ENV not in ENV_NOT_ALLOWED:
try:
send_smtp_email = sib_api_v3_sdk.SendSmtpEmail(**data)
response = api_instance.send_transac_email(send_smtp_email)
logger.info("Brevo: send transactional email with template")
# {'message_id': '<202407151419.84958140835@smtp-relay.mailin.fr>', 'message_ids': None}
+ template_transactional.extra_data = {**log_args, "sent": True}
+ template_transactional.save(update_fields=["extra_data"])
return response.to_dict()
except ApiException as e:
print(f"Exception when calling SMTPApi->send_transac_email: {e}")
diff --git a/lemarche/utils/apis/api_mailjet.py b/lemarche/utils/apis/api_mailjet.py
index d1e95fd64..e9a5b8c1e 100644
--- a/lemarche/utils/apis/api_mailjet.py
+++ b/lemarche/utils/apis/api_mailjet.py
@@ -4,6 +4,7 @@
from django.conf import settings
from huey.contrib.djhuey import task
+from lemarche.conversations.constants import SOURCE_MAILJET
from lemarche.users import constants as user_constants
from lemarche.utils.constants import EMAIL_SUBJECT_PREFIX
@@ -95,6 +96,9 @@ def add_to_contact_list_async(email_address, properties, contact_list_id, client
@task()
def send_transactional_email_with_template(
+ template_transactional,
+ recipient_content_object,
+ parent_content_object,
template_id: int,
recipient_email: str,
recipient_name: str,
@@ -122,12 +126,37 @@ def send_transactional_email_with_template(
if not client:
client = get_default_client()
+ log_args = {
+ "source": SOURCE_MAILJET,
+ "args": {
+ "template_id": template_id,
+ "recipient_email": recipient_email,
+ "recipient_name": recipient_name,
+ "variables": variables,
+ "subject": subject,
+ "from_email": from_email,
+ "from_name": from_name,
+ },
+ }
+
+ # create log
+ template_transactional.send_logs.create(
+ recipient_content_object=recipient_content_object,
+ parent_content_object=parent_content_object,
+ extra_data={
+ **log_args,
+ "sent": settings.BITOUBI_ENV in ENV_NOT_ALLOWED, # considered successfully in tests and dev
+ },
+ )
+
if settings.BITOUBI_ENV not in ENV_NOT_ALLOWED:
try:
response = client.post(SEND_URL, json=data)
response.raise_for_status()
logger.info("Mailjet: send transactional email with template")
# {'Messages': [{'Status': 'success', 'CustomID': '', 'To': [{'Email': '', 'MessageUUID': '', 'MessageID': , 'MessageHref': 'https://api.mailjet.com/v3/REST/message/'}], 'Cc': [], 'Bcc': []}]} # noqa
+ template_transactional.extra_data = {**log_args, "sent": True}
+ template_transactional.save(update_fields=["extra_data"])
return response.json()
except requests.exceptions.HTTPError as e:
logger.error("Error while fetching `%s`: %s", e.request.url, e)
diff --git a/lemarche/utils/s3boto.py b/lemarche/utils/s3boto.py
deleted file mode 100644
index c42aadabc..000000000
--- a/lemarche/utils/s3boto.py
+++ /dev/null
@@ -1,520 +0,0 @@
-# copy from django-storage-1.8
-# replace force_text by force_str
-import io
-import mimetypes
-import os
-import warnings
-from datetime import datetime
-from gzip import GzipFile
-from tempfile import SpooledTemporaryFile
-
-from django.conf import settings as django_settings
-from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
-from django.core.files.base import File
-from django.core.files.storage import Storage
-from django.utils import timezone as tz
-from django.utils.deconstruct import deconstructible
-from django.utils.encoding import filepath_to_uri, force_bytes, force_str, smart_str
-from storages.utils import check_location, clean_name, get_available_overwrite_name, lookup_env, safe_join, setting
-
-
-try:
- from boto import __version__ as boto_version
- from boto.exception import S3ResponseError
- from boto.s3.connection import Location, S3Connection, SubdomainCallingFormat
- from boto.s3.key import Key as S3Key
- from boto.utils import ISO8601, parse_ts
-except ImportError:
- raise ImproperlyConfigured("Could not load Boto's S3 bindings.\n" "See https://github.com/boto/boto")
-
-
-boto_version_info = tuple([int(i) for i in boto_version.split("-")[0].split(".")])
-
-if boto_version_info[:2] < (2, 32):
- raise ImproperlyConfigured(
- "The installed Boto library must be 2.32 or " "higher.\nSee https://github.com/boto/boto"
- )
-
-warnings.warn(
- "The S3BotoStorage backend is deprecated in favor of the S3Boto3Storage backend "
- "and will be removed in django-storages 1.8. This backend is mostly in bugfix only "
- "mode and has been for quite a while (in much the same way as its underlying "
- "library 'boto'). For performance, security and new feature reasons it is _strongly_ "
- "recommended that you update to the S3Boto3Storage backend. Please see the migration docs "
- "https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#migrating-boto-to-boto3.",
- DeprecationWarning,
-)
-
-
-@deconstructible
-class S3BotoStorageFile(File):
- """
- The default file object used by the S3BotoStorage backend.
- This file implements file streaming using boto's multipart
- uploading functionality. The file can be opened in read or
- write mode.
- This class extends Django's File class. However, the contained
- data is only the data contained in the current buffer. So you
- should not access the contained file object directly. You should
- access the data via this class.
- Warning: This file *must* be closed using the close() method in
- order to properly write the file to S3. Be sure to close the file
- in your application.
- """
-
- # TODO: Read/Write (rw) mode may be a bit undefined at the moment. Needs testing.
- # TODO: When Django drops support for Python 2.5, rewrite to use the
- # BufferedIO streams in the Python 2.6 io module.
- buffer_size = setting("AWS_S3_FILE_BUFFER_SIZE", 5242880)
-
- def __init__(self, name, mode, storage, buffer_size=None):
- self._storage = storage
- self.name = name[len(self._storage.location) :].lstrip("/") # noqa
- self._mode = mode
- self.key = storage.bucket.get_key(self._storage._encode_name(name))
- if not self.key and "w" in mode:
- self.key = storage.bucket.new_key(storage._encode_name(name))
- self._is_dirty = False
- self._file = None
- self._multipart = None
- # 5 MB is the minimum part size (if there is more than one part).
- # Amazon allows up to 10,000 parts. The default supports uploads
- # up to roughly 50 GB. Increase the part size to accommodate
- # for files larger than this.
- if buffer_size is not None:
- self.buffer_size = buffer_size
- self._write_counter = 0
-
- if not hasattr(django_settings, "AWS_DEFAULT_ACL"):
- warnings.warn(
- "The default behavior of S3BotoStorage is insecure. By default files "
- "and new buckets are saved with an ACL of 'public-read' (globally "
- "publicly readable). To change to using the bucket's default ACL "
- "set AWS_DEFAULT_ACL = None, otherwise to silence this warning "
- "explicitly set AWS_DEFAULT_ACL."
- )
-
- @property
- def size(self):
- return self.key.size
-
- def _get_file(self):
- if self._file is None:
- self._file = SpooledTemporaryFile(
- max_size=self._storage.max_memory_size,
- suffix=".S3BotoStorageFile",
- dir=setting("FILE_UPLOAD_TEMP_DIR"),
- )
- if "r" in self._mode:
- self._is_dirty = False
- self.key.get_contents_to_file(self._file)
- self._file.seek(0)
- if self._storage.gzip and self.key.content_encoding == "gzip":
- self._file = GzipFile(mode=self._mode, fileobj=self._file)
- return self._file
-
- def _set_file(self, value):
- self._file = value
-
- file = property(_get_file, _set_file)
-
- def read(self, *args, **kwargs):
- if "r" not in self._mode:
- raise AttributeError("File was not opened in read mode.")
- return super(S3BotoStorageFile, self).read(*args, **kwargs)
-
- def write(self, content, *args, **kwargs):
- if "w" not in self._mode:
- raise AttributeError("File was not opened in write mode.")
- self._is_dirty = True
- if self._multipart is None:
- provider = self.key.bucket.connection.provider
- upload_headers = {}
- if self._storage.default_acl:
- upload_headers[provider.acl_header] = self._storage.default_acl
- upload_headers.update(
- {"Content-Type": mimetypes.guess_type(self.key.name)[0] or self._storage.key_class.DefaultContentType}
- )
- upload_headers.update(self._storage.headers)
- self._multipart = self._storage.bucket.initiate_multipart_upload(
- self.key.name,
- headers=upload_headers,
- reduced_redundancy=self._storage.reduced_redundancy,
- encrypt_key=self._storage.encryption,
- )
- if self.buffer_size <= self._buffer_file_size:
- self._flush_write_buffer()
- return super(S3BotoStorageFile, self).write(force_bytes(content), *args, **kwargs)
-
- @property
- def _buffer_file_size(self):
- pos = self.file.tell()
- self.file.seek(0, os.SEEK_END)
- length = self.file.tell()
- self.file.seek(pos)
- return length
-
- def _flush_write_buffer(self):
- if self._buffer_file_size:
- self._write_counter += 1
- self.file.seek(0)
- headers = self._storage.headers.copy()
- self._multipart.upload_part_from_file(self.file, self._write_counter, headers=headers)
- self.file.seek(0)
- self.file.truncate()
-
- def close(self):
- if self._is_dirty:
- self._flush_write_buffer()
- self._multipart.complete_upload()
- else:
- if self._multipart is not None:
- self._multipart.cancel_upload()
- self.key.close()
- if self._file is not None:
- self._file.close()
- self._file = None
-
-
-@deconstructible
-class S3BotoStorage(Storage):
- """
- Amazon Simple Storage Service using Boto
- This storage backend supports opening files in read or write
- mode and supports streaming(buffering) data in chunks to S3
- when writing.
- """
-
- connection_class = S3Connection
- connection_response_error = S3ResponseError
- file_class = S3BotoStorageFile
- key_class = S3Key
- # used for looking up the access and secret key from env vars
- access_key_names = ["S3_STORAGE_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID"]
- secret_key_names = ["S3_STORAGE_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY"]
- security_token_names = ["AWS_SESSION_TOKEN", "AWS_SECURITY_TOKEN"]
- security_token = None
-
- access_key = setting("S3_STORAGE_ACCESS_KEY_ID", setting("AWS_ACCESS_KEY_ID"))
- secret_key = setting("S3_STORAGE_SECRET_ACCESS_KEY", setting("AWS_SECRET_ACCESS_KEY"))
- file_overwrite = setting("AWS_S3_FILE_OVERWRITE", True)
- headers = setting("AWS_HEADERS", {})
- bucket_name = setting("S3_STORAGE_BUCKET_NAME")
- auto_create_bucket = setting("AWS_AUTO_CREATE_BUCKET", False)
- default_acl = setting("AWS_DEFAULT_ACL", "public-read")
- bucket_acl = setting("AWS_BUCKET_ACL", default_acl)
- querystring_auth = setting("AWS_QUERYSTRING_AUTH", True)
- querystring_expire = setting("AWS_QUERYSTRING_EXPIRE", 3600)
- reduced_redundancy = setting("AWS_REDUCED_REDUNDANCY", False)
- location = setting("AWS_LOCATION", "")
- origin = setting("AWS_ORIGIN", Location.DEFAULT)
- encryption = setting("AWS_S3_ENCRYPTION", False)
- custom_domain = setting("AWS_S3_CUSTOM_DOMAIN")
- calling_format = setting("AWS_S3_CALLING_FORMAT", SubdomainCallingFormat())
- secure_urls = setting("AWS_S3_SECURE_URLS", True)
- file_name_charset = setting("AWS_S3_FILE_NAME_CHARSET", "utf-8")
- gzip = setting("AWS_IS_GZIPPED", False)
- preload_metadata = setting("AWS_PRELOAD_METADATA", False)
- gzip_content_types = setting(
- "GZIP_CONTENT_TYPES",
- (
- "text/css",
- "text/javascript",
- "application/javascript",
- "application/x-javascript",
- "image/svg+xml",
- ),
- )
- url_protocol = setting("AWS_S3_URL_PROTOCOL", "https:")
- host = setting("S3_STORAGE_ENDPOINT_DOMAIN", S3Connection.DefaultHost)
- use_ssl = setting("AWS_S3_USE_SSL", True)
- port = setting("AWS_S3_PORT")
- proxy = setting("AWS_S3_PROXY_HOST")
- proxy_port = setting("AWS_S3_PROXY_PORT")
- max_memory_size = setting("AWS_S3_MAX_MEMORY_SIZE", 0)
-
- def __init__(self, acl=None, bucket=None, **settings):
- # check if some of the settings we've provided as class attributes
- # need to be overwritten with values passed in here
- for name, value in settings.items():
- if hasattr(self, name):
- setattr(self, name, value)
-
- # For backward-compatibility of old differing parameter names
- if acl is not None:
- self.default_acl = acl
- if bucket is not None:
- self.bucket_name = bucket
-
- check_location(self)
-
- # Backward-compatibility: given the anteriority of the SECURE_URL setting
- # we fall back to https if specified in order to avoid the construction
- # of unsecure urls.
- if self.secure_urls:
- self.url_protocol = "https:"
-
- self._entries = {}
- self._bucket = None
- self._connection = None
- self._loaded_meta = False
-
- self.access_key, self.secret_key = self._get_access_keys()
- self.security_token = self._get_security_token()
-
- @property
- def connection(self):
- if self._connection is None:
- kwargs = self._get_connection_kwargs()
-
- self._connection = self.connection_class(self.access_key, self.secret_key, **kwargs)
- return self._connection
-
- def _get_connection_kwargs(self):
- return dict(
- security_token=self.security_token,
- is_secure=self.use_ssl,
- calling_format=self.calling_format,
- host=self.host,
- port=self.port,
- proxy=self.proxy,
- proxy_port=self.proxy_port,
- )
-
- @property
- def bucket(self):
- """
- Get the current bucket. If there is no current bucket object
- create it.
- """
- if self._bucket is None:
- self._bucket = self._get_or_create_bucket(self.bucket_name)
- return self._bucket
-
- @property
- def entries(self):
- """
- Get the locally cached files for the bucket.
- """
- if self.preload_metadata and not self._loaded_meta:
- self._entries.update(
- {self._decode_name(entry.key): entry for entry in self.bucket.list(prefix=self.location)}
- )
- self._loaded_meta = True
- return self._entries
-
- def _get_access_keys(self):
- """
- Gets the access keys to use when accessing S3. If none is
- provided in the settings then get them from the environment
- variables.
- """
- access_key = self.access_key or lookup_env(S3BotoStorage.access_key_names)
- secret_key = self.secret_key or lookup_env(S3BotoStorage.secret_key_names)
- return access_key, secret_key
-
- def _get_security_token(self):
- """
- Gets the security token to use when accessing S3. Get it from
- the environment variables.
- """
- security_token = self.security_token or lookup_env(S3BotoStorage.security_token_names)
- return security_token
-
- def _get_or_create_bucket(self, name):
- """
- Retrieves a bucket if it exists, otherwise creates it.
- """
- try:
- return self.connection.get_bucket(name, validate=self.auto_create_bucket)
- except self.connection_response_error:
- if self.auto_create_bucket:
- bucket = self.connection.create_bucket(name, location=self.origin)
- if not hasattr(django_settings, "AWS_BUCKET_ACL"):
- warnings.warn(
- "The default behavior of S3BotoStorage is insecure. By default new buckets "
- "are saved with an ACL of 'public-read' (globally publicly readable). To change "
- "to using Amazon's default of the bucket owner set AWS_DEFAULT_ACL = None, "
- "otherwise to silence this warning explicitly set AWS_DEFAULT_ACL."
- )
- if self.bucket_acl:
- bucket.set_acl(self.bucket_acl)
- return bucket
- raise ImproperlyConfigured(
- "Bucket %s does not exist. Buckets "
- "can be automatically created by "
- "setting AWS_AUTO_CREATE_BUCKET to "
- "``True``." % name
- )
-
- def _clean_name(self, name):
- """
- Cleans the name so that Windows style paths work
- """
- return clean_name(name)
-
- def _normalize_name(self, name):
- """
- Normalizes the name so that paths like /path/to/ignored/../something.txt
- work. We check to make sure that the path pointed to is not outside
- the directory specified by the LOCATION setting.
- """
- try:
- return safe_join(self.location, name)
- except ValueError:
- raise SuspiciousOperation("Attempted access to '%s' denied." % name)
-
- def _encode_name(self, name):
- return smart_str(name, encoding=self.file_name_charset)
-
- def _decode_name(self, name):
- return force_str(name, encoding=self.file_name_charset)
-
- def _compress_content(self, content):
- """Gzip a given string content."""
- zbuf = io.BytesIO()
- # The GZIP header has a modification time attribute (see http://www.zlib.org/rfc-gzip.html)
- # This means each time a file is compressed it changes even if the other contents don't change
- # For S3 this defeats detection of changes using MD5 sums on gzipped files
- # Fixing the mtime at 0.0 at compression time avoids this problem
- zfile = GzipFile(mode="wb", fileobj=zbuf, mtime=0.0)
- try:
- zfile.write(force_bytes(content.read()))
- finally:
- zfile.close()
- zbuf.seek(0)
- content.file = zbuf
- content.seek(0)
- return content
-
- def _open(self, name, mode="rb"):
- name = self._normalize_name(self._clean_name(name))
- f = self.file_class(name, mode, self)
- if not f.key:
- raise IOError("File does not exist: %s" % name)
- return f
-
- def _save(self, name, content):
- cleaned_name = self._clean_name(name)
- name = self._normalize_name(cleaned_name)
- headers = self.headers.copy()
- _type, encoding = mimetypes.guess_type(name)
- content_type = getattr(content, "content_type", None)
- content_type = content_type or _type or self.key_class.DefaultContentType
-
- # setting the content_type in the key object is not enough.
- headers.update({"Content-Type": content_type})
-
- if self.gzip and content_type in self.gzip_content_types:
- content = self._compress_content(content)
- headers.update({"Content-Encoding": "gzip"})
- elif encoding:
- # If the content already has a particular encoding, set it
- headers.update({"Content-Encoding": encoding})
-
- content.name = cleaned_name
- encoded_name = self._encode_name(name)
- key = self.bucket.get_key(encoded_name)
- if not key:
- key = self.bucket.new_key(encoded_name)
- if self.preload_metadata:
- self._entries[encoded_name] = key
- key.last_modified = datetime.utcnow().strftime(ISO8601)
-
- key.set_metadata("Content-Type", content_type)
- self._save_content(key, content, headers=headers)
- return cleaned_name
-
- def _save_content(self, key, content, headers):
- # only pass backwards incompatible arguments if they vary from the default
- kwargs = {}
- if self.encryption:
- kwargs["encrypt_key"] = self.encryption
- key.set_contents_from_file(
- content,
- headers=headers,
- policy=self.default_acl,
- reduced_redundancy=self.reduced_redundancy,
- rewind=True,
- **kwargs,
- )
-
- def _get_key(self, name):
- name = self._normalize_name(self._clean_name(name))
- if self.entries:
- return self.entries.get(name)
- return self.bucket.get_key(self._encode_name(name))
-
- def delete(self, name):
- name = self._normalize_name(self._clean_name(name))
- self.bucket.delete_key(self._encode_name(name))
-
- def exists(self, name):
- if not name: # root element aka the bucket
- try:
- self.bucket
- return True
- except ImproperlyConfigured:
- return False
-
- return self._get_key(name) is not None
-
- def listdir(self, name):
- name = self._normalize_name(self._clean_name(name))
- # for the bucket.list and logic below name needs to end in /
- # But for the root path "" we leave it as an empty string
- if name and not name.endswith("/"):
- name += "/"
-
- dirlist = self.bucket.list(self._encode_name(name))
- files = []
- dirs = set()
- base_parts = name.split("/")[:-1]
- for item in dirlist:
- parts = item.name.split("/")
- parts = parts[len(base_parts) :] # noqa
- if len(parts) == 1:
- # File
- files.append(parts[0])
- elif len(parts) > 1:
- # Directory
- dirs.add(parts[0])
- return list(dirs), files
-
- def size(self, name):
- return self._get_key(name).size
-
- def get_modified_time(self, name):
- dt = tz.make_aware(parse_ts(self._get_key(name).last_modified), tz.utc)
- return dt if setting("USE_TZ") else tz.make_naive(dt)
-
- def modified_time(self, name):
- dt = tz.make_aware(parse_ts(self._get_key(name).last_modified), tz.utc)
- return tz.make_naive(dt)
-
- def url(self, name, headers=None, response_headers=None, expire=None):
- # Preserve the trailing slash after normalizing the path.
- name = self._normalize_name(self._clean_name(name))
- if self.custom_domain:
- return "%s//%s/%s" % (self.url_protocol, self.custom_domain, filepath_to_uri(name))
-
- if expire is None:
- expire = self.querystring_expire
-
- return self.connection.generate_url(
- expire,
- method="GET",
- bucket=self.bucket.name,
- key=self._encode_name(name),
- headers=headers,
- query_auth=self.querystring_auth,
- force_http=not self.secure_urls,
- response_headers=response_headers,
- )
-
- def get_available_name(self, name, max_length=None):
- """Overwrite existing file with the same name."""
- name = self._clean_name(name)
- if self.file_overwrite:
- return get_available_overwrite_name(name, max_length)
- return super(S3BotoStorage, self).get_available_name(name, max_length)