send mail to inactive users

This commit is contained in:
imperosol 2024-10-06 22:24:20 +02:00
parent 6a64e05247
commit 5a8052ae47
10 changed files with 541 additions and 207 deletions

View File

@ -49,6 +49,18 @@ class BillingInfoAdmin(admin.ModelAdmin):
autocomplete_fields = ("customer",) autocomplete_fields = ("customer",)
@admin.register(AccountDump)
class AccountDumpAdmin(admin.ModelAdmin):
list_display = (
"customer",
"warning_mail_sent_at",
"warning_mail_error",
"dump_operation",
)
autocomplete_fields = ("customer",)
list_filter = ("warning_mail_error",)
@admin.register(Counter) @admin.register(Counter)
class CounterAdmin(admin.ModelAdmin): class CounterAdmin(admin.ModelAdmin):
list_display = ("name", "club", "type") list_display = ("name", "club", "type")

View File

View File

View File

@ -0,0 +1,88 @@
import logging
from smtplib import SMTPException
from django.conf import settings
from django.core.mail import send_mail
from django.core.management.base import BaseCommand
from django.db.models import Exists, OuterRef, Subquery
from django.template.loader import render_to_string
from django.utils.timezone import localdate, now
from django.utils.translation import gettext as _
from core.models import User
from counter.models import AccountDump
from subscription.models import Subscription
class Command(BaseCommand):
"""Send mail to inactive users, warning them that their account is about to be dumped.
This command should be automated with a cron task.
"""
def __init__(self, *args, **kwargs):
self.logger = logging.getLogger("account_dump_mail")
self.logger.setLevel(logging.INFO)
super().__init__(*args, **kwargs)
def handle(self, *args, **options):
ongoing_dump_operation = AccountDump.objects.ongoing().filter(
customer__user=OuterRef("pk")
)
users = list(
User.objects
.filter_inactive()
.filter(customer__amount__gt=0)
.exclude(Exists(ongoing_dump_operation))
.annotate(
last_subscription_date=Subquery(
Subscription.objects.filter(member=OuterRef("pk"))
.order_by("-subscription_end")
.values("subscription_end")[:1]
),
)
.select_related("customer")
)
self.stdout.write(f"{len(users)} users will be warned of their account dump")
dumps = []
for user in users:
is_success = self._send_mail(user)
dumps.append(
AccountDump(
customer_id=user.id,
warning_mail_sent_at=now(),
warning_mail_error=not is_success,
)
)
AccountDump.objects.bulk_create(dumps)
self.stdout.write("Finished !")
def _send_mail(self, user: User) -> bool:
"""Send the warning email to the given user.
Returns:
True if the mail was successfully sent, else False
"""
message = render_to_string(
"counter/account_dump_warning_mail.jinja",
{
"balance": user.customer.amount,
"last_subscription_date": user.last_subscription_date,
"dump_date": localdate() + settings.SITH_ACCOUNT_DUMP_DELTA,
},
)
try:
# sending mails one by one is long and ineffective,
# but it makes easier to know which emails failed (and how).
# Also, there won't be that much mails sent (except on the first run)
send_mail(
_("Clearing of your AE account"),
message,
settings.DEFAULT_FROM_EMAIL,
[user.email],
)
self.logger.info(f"Mail successfully sent to {user.email}")
return True
except SMTPException as e:
self.logger.error(f"failed mail to {user.email} :\n{e}")
return False

View File

@ -0,0 +1,64 @@
# Generated by Django 4.2.16 on 2024-10-06 14:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0023_billinginfo_phone_number")]
operations = [
migrations.CreateModel(
name="AccountDump",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"warning_mail_sent_at",
models.DateTimeField(
help_text="When the mail warning that the account was about to be dumped was sent."
),
),
(
"warning_mail_error",
models.BooleanField(
default=False,
help_text="Set this to True if the warning mail received an error",
),
),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="dumps",
to="counter.customer",
),
),
(
"dump_operation",
models.OneToOneField(
blank=True,
help_text="The operation that emptied the account.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="counter.selling",
),
),
],
),
migrations.AddConstraint(
model_name="accountdump",
constraint=models.UniqueConstraint(
condition=models.Q(("dump_operation", None)),
fields=("customer",),
name="unique_ongoing_dump",
),
),
]

View File

@ -26,7 +26,7 @@ from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
from django.db.models import Exists, F, OuterRef, QuerySet, Sum, Value from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum, Value
from django.db.models.functions import Concat, Length from django.db.models.functions import Concat, Length
from django.forms import ValidationError from django.forms import ValidationError
from django.urls import reverse from django.urls import reverse
@ -211,6 +211,51 @@ class BillingInfo(models.Model):
return '<?xml version="1.0" encoding="UTF-8" ?>' + xml return '<?xml version="1.0" encoding="UTF-8" ?>' + xml
class AccountDumpQuerySet(models.QuerySet):
def ongoing(self) -> Self:
"""Filter dump operations that are not completed yet."""
return self.filter(dump_operation=None)
class AccountDump(models.Model):
"""The process of dumping an account."""
customer = models.ForeignKey(
Customer, related_name="dumps", on_delete=models.CASCADE
)
warning_mail_sent_at = models.DateTimeField(
help_text=_(
"When the mail warning that the account was about to be dumped was sent."
)
)
warning_mail_error = models.BooleanField(
default=False,
help_text=_("Set this to True if the warning mail received an error"),
)
dump_operation = models.OneToOneField(
"Selling",
null=True,
blank=True,
on_delete=models.CASCADE,
help_text=_("The operation that emptied the account."),
)
objects = AccountDumpQuerySet.as_manager()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["customer"],
condition=Q(dump_operation=None),
name="unique_ongoing_dump",
),
]
def __str__(self):
status = "ongoing" if self.dump_operation is None else "finished"
return f"{self.customer} - {status}"
class ProductType(models.Model): class ProductType(models.Model):
"""A product type. """A product type.

View File

@ -0,0 +1,43 @@
<p>
Bonjour,
</p>
<p>
{% trans date=last_subscription_date|date(DATETIME_FORMAT) %}
You received this email because your last subscription to the
Students' association ended on {{ date }}.
{% endtrans %}
</p>
<p>
{% trans date=dump_date|date(DATETIME_FORMAT) %}
In accordance with the Internal Regulations, the balance of any
inactive AE account for more than 2 years automatically goes back
to the AE.
The money present on your account will therefore be recovered in full
on {{ date }}.
{% endtrans %}
</p>
<p>
{% trans %}However, if your subscription is renewed by this date,
your right to keep the money in your AE account will be renewed.{% endtrans %}
</p>
{% if balance >= 10 %}
<p>
{% trans %}You can also request a refund by sending an email to
<a href="mailto:ae@utbm.fr">ae@utbm.fr</a>
before the aforementioned date.{% endtrans %}
</p>
{% endif %}
<p>
{% trans %}Sincerely{% endtrans %},
</p>
<p>
L'association des étudiants de l'UTBM <br>
6, Boulevard Anatole France <br>
90000 Belfort
</p>

View File

@ -0,0 +1,7 @@
import pytest
@pytest.mark.django_db
def test_account_dump():
# TODO write the fucking test
pass

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@ import binascii
import logging import logging
import os import os
import sys import sys
from datetime import timedelta
from pathlib import Path from pathlib import Path
import sentry_sdk import sentry_sdk
@ -229,13 +230,20 @@ LOGGING = {
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "simple", "formatter": "simple",
}, },
"dump_mail_file": {
"level": "DEBUG",
"class": "logging.FileHandler",
"filename": "account_dump_mail.log",
"formatter": "simple",
},
}, },
"loggers": { "loggers": {
"main": { "main": {
"handlers": ["log_to_stdout"], "handlers": ["log_to_stdout"],
"level": "INFO", "level": "INFO",
"propagate": True, "propagate": True,
} },
"account_dump_mail": {"handlers": ["dump_mail_file", "log_to_stdout"]},
}, },
} }
@ -498,6 +506,8 @@ SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2) SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2)
"""Time before which a user account is considered inactive""" """Time before which a user account is considered inactive"""
SITH_ACCOUNT_DUMP_DELTA = timedelta(days=30)
"""timedelta between the warning mail and the actual account dump"""
# Defines which product type is the refilling type, and thus increases the account amount # Defines which product type is the refilling type, and thus increases the account amount
SITH_COUNTER_PRODUCTTYPE_REFILLING = 3 SITH_COUNTER_PRODUCTTYPE_REFILLING = 3