Merge pull request #861 from ae-utbm/mail-inactives

Send mail to inactive users
This commit is contained in:
thomas girod 2024-10-12 15:33:23 +02:00 committed by GitHub
commit 1c774aa4a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 865 additions and 387 deletions

View File

@ -1,7 +1,8 @@
from datetime import timedelta from datetime import timedelta
from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.utils.timezone import now from django.utils.timezone import localdate, now
from model_bakery import seq from model_bakery import seq
from model_bakery.recipe import Recipe, related from model_bakery.recipe import Recipe, related
@ -11,13 +12,13 @@ from subscription.models import Subscription
active_subscription = Recipe( active_subscription = Recipe(
Subscription, Subscription,
subscription_start=now() - timedelta(days=30), subscription_start=localdate() - timedelta(days=30),
subscription_end=now() + timedelta(days=30), subscription_end=localdate() + timedelta(days=30),
) )
ended_subscription = Recipe( ended_subscription = Recipe(
Subscription, Subscription,
subscription_start=now() - timedelta(days=60), subscription_start=localdate() - timedelta(days=60),
subscription_end=now() - timedelta(days=30), subscription_end=localdate() - timedelta(days=30),
) )
subscriber_user = Recipe( subscriber_user = Recipe(
@ -36,6 +37,17 @@ old_subscriber_user = Recipe(
) )
"""A user with an ended subscription.""" """A user with an ended subscription."""
__inactivity = localdate() - settings.SITH_ACCOUNT_INACTIVITY_DELTA
very_old_subscriber_user = old_subscriber_user.extend(
subscriptions=related(
ended_subscription.extend(
subscription_start=__inactivity - relativedelta(months=6, days=1),
subscription_end=__inactivity - relativedelta(days=1),
)
)
)
"""A user which subscription ended enough time ago to be considered as inactive."""
ae_board_membership = Recipe( ae_board_membership = Recipe(
Membership, Membership,
start_date=now() - timedelta(days=30), start_date=now() - timedelta(days=30),

View File

@ -0,0 +1,15 @@
# Generated by Django 4.2.16 on 2024-10-06 14:52
from django.db import migrations
import core.models
class Migration(migrations.Migration):
dependencies = [("core", "0038_alter_preferences_receive_weekmail")]
operations = [
migrations.AlterModelManagers(
name="user", managers=[("objects", core.models.CustomUserManager())]
)
]

View File

@ -27,15 +27,12 @@ import importlib
import logging import logging
import os import os
import unicodedata import unicodedata
from datetime import date, timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Any, Optional, Self
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import ( from django.contrib.auth.models import AbstractBaseUser, UserManager
AbstractBaseUser,
UserManager,
)
from django.contrib.auth.models import ( from django.contrib.auth.models import (
AnonymousUser as AuthAnonymousUser, AnonymousUser as AuthAnonymousUser,
) )
@ -51,15 +48,18 @@ from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, OuterRef, Q
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape from django.utils.html import escape
from django.utils.timezone import localdate, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from pydantic.v1 import NonNegativeInt
if TYPE_CHECKING: if TYPE_CHECKING:
from pydantic import NonNegativeInt
from club.models import Club from club.models import Club
@ -91,15 +91,15 @@ class Group(AuthGroup):
class Meta: class Meta:
ordering = ["name"] ordering = ["name"]
def get_absolute_url(self): def get_absolute_url(self) -> str:
return reverse("core:group_list") return reverse("core:group_list")
def save(self, *args, **kwargs): def save(self, *args, **kwargs) -> None:
super().save(*args, **kwargs) super().save(*args, **kwargs)
cache.set(f"sith_group_{self.id}", self) cache.set(f"sith_group_{self.id}", self)
cache.set(f"sith_group_{self.name.replace(' ', '_')}", self) cache.set(f"sith_group_{self.name.replace(' ', '_')}", self)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs) -> None:
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
cache.delete(f"sith_group_{self.id}") cache.delete(f"sith_group_{self.id}")
cache.delete(f"sith_group_{self.name.replace(' ', '_')}") cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
@ -164,9 +164,9 @@ class RealGroup(Group):
proxy = True proxy = True
def validate_promo(value): def validate_promo(value: int) -> None:
start_year = settings.SITH_SCHOOL_START_YEAR start_year = settings.SITH_SCHOOL_START_YEAR
delta = (date.today() + timedelta(days=180)).year - start_year delta = (localdate() + timedelta(days=180)).year - start_year
if value < 0 or delta < value: if value < 0 or delta < value:
raise ValidationError( raise ValidationError(
_("%(value)s is not a valid promo (between 0 and %(end)s)"), _("%(value)s is not a valid promo (between 0 and %(end)s)"),
@ -174,7 +174,7 @@ def validate_promo(value):
) )
def get_group(*, pk: int = None, name: str = None) -> Optional[Group]: def get_group(*, pk: int = None, name: str = None) -> Group | None:
"""Search for a group by its primary key or its name. """Search for a group by its primary key or its name.
Either one of the two must be set. Either one of the two must be set.
@ -216,6 +216,31 @@ def get_group(*, pk: int = None, name: str = None) -> Optional[Group]:
return group return group
class UserQuerySet(models.QuerySet):
def filter_inactive(self) -> Self:
from counter.models import Refilling, Selling
from subscription.models import Subscription
threshold = now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA
subscriptions = Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__gt=localdate(threshold)
)
refills = Refilling.objects.filter(
customer__user_id=OuterRef("pk"), date__gt=threshold
)
purchases = Selling.objects.filter(
customer__user_id=OuterRef("pk"), date__gt=threshold
)
return self.exclude(
Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
)
class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
pass
class User(AbstractBaseUser): class User(AbstractBaseUser):
"""Defines the base user class, useable in every app. """Defines the base user class, useable in every app.
@ -373,36 +398,41 @@ class User(AbstractBaseUser):
) )
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = UserManager() objects = CustomUserManager()
USERNAME_FIELD = "username" USERNAME_FIELD = "username"
def promo_has_logo(self):
return Path(
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
).exists()
def has_module_perms(self, package_name):
return self.is_active
def has_perm(self, perm, obj=None):
return self.is_active and self.is_superuser
def get_absolute_url(self):
return reverse("core:user_profile", kwargs={"user_id": self.pk})
def __str__(self): def __str__(self):
return self.get_display_name() return self.get_display_name()
def to_dict(self): def save(self, *args, **kwargs):
return self.__dict__ with transaction.atomic():
if self.id:
old = User.objects.filter(id=self.id).first()
if old and old.username != self.username:
self._change_username(self.username)
super().save(*args, **kwargs)
def get_absolute_url(self) -> str:
return reverse("core:user_profile", kwargs={"user_id": self.pk})
def promo_has_logo(self) -> bool:
return Path(
settings.BASE_DIR / f"core/static/core/img/promo_{self.promo}.png"
).exists()
def has_module_perms(self, package_name: str) -> bool:
return self.is_active
def has_perm(self, perm: str, obj: Any = None) -> bool:
return self.is_active and self.is_superuser
@cached_property @cached_property
def was_subscribed(self): def was_subscribed(self) -> bool:
return self.subscriptions.exists() return self.subscriptions.exists()
@cached_property @cached_property
def is_subscribed(self): def is_subscribed(self) -> bool:
s = self.subscriptions.filter( s = self.subscriptions.filter(
subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now() subscription_start__lte=timezone.now(), subscription_end__gte=timezone.now()
) )
@ -542,17 +572,6 @@ class User(AbstractBaseUser):
) )
return age return age
def save(self, *args, **kwargs):
create = False
with transaction.atomic():
if self.id:
old = User.objects.filter(id=self.id).first()
if old and old.username != self.username:
self._change_username(self.username)
else:
create = True
super().save(*args, **kwargs)
def make_home(self): def make_home(self):
if self.home is None: if self.home is None:
home_root = SithFile.objects.filter(parent=None, name="users").first() home_root = SithFile.objects.filter(parent=None, name="users").first()

View File

@ -1,6 +1,7 @@
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
@ -8,8 +9,13 @@ from django.utils.timezone import now
from model_bakery import baker, seq from model_bakery import baker, seq
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from core.baker_recipes import subscriber_user from core.baker_recipes import (
old_subscriber_user,
subscriber_user,
very_old_subscriber_user,
)
from core.models import User from core.models import User
from counter.models import Counter, Refilling, Selling
class TestSearchUsers(TestCase): class TestSearchUsers(TestCase):
@ -111,3 +117,34 @@ def test_user_account_not_found(client: Client):
) )
) )
assert res.status_code == 404 assert res.status_code == 404
class TestFilterInactive(TestCase):
@classmethod
def setUpTestData(cls):
time_active = now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA + timedelta(days=1)
time_inactive = time_active - timedelta(days=3)
counter, seller = baker.make(Counter), baker.make(User)
sale_recipe = Recipe(
Selling,
counter=counter,
club=counter.club,
seller=seller,
is_validated=True,
)
cls.users = [
baker.make(User),
subscriber_user.make(),
old_subscriber_user.make(),
*very_old_subscriber_user.make(_quantity=3),
]
sale_recipe.make(customer=cls.users[3].customer, date=time_active)
baker.make(
Refilling, customer=cls.users[4].customer, date=time_active, counter=counter
)
sale_recipe.make(customer=cls.users[5].customer, date=time_inactive)
def test_filter_inactive(self):
res = User.objects.filter(id__in=[u.id for u in self.users]).filter_inactive()
assert list(res) == [self.users[0], self.users[5]]

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,91 @@
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, QuerySet, 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):
users = list(self._get_users())
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 !")
@staticmethod
def _get_users() -> QuerySet[User]:
ongoing_dump_operation = AccountDump.objects.ongoing().filter(
customer__user=OuterRef("pk")
)
return (
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")
)
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), amount=balance -%}
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 }}, for a total of {{ amount }} €.
{%- 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,65 @@
from datetime import timedelta
from django.conf import settings
from django.core import mail
from django.core.management import call_command
from django.test import TestCase
from django.utils.timezone import now
from model_bakery import baker
from model_bakery.recipe import Recipe
from core.baker_recipes import subscriber_user, very_old_subscriber_user
from counter.management.commands.dump_warning_mail import Command
from counter.models import AccountDump, Customer, Refilling
class TestAccountDumpWarningMailCommand(TestCase):
@classmethod
def setUpTestData(cls):
# delete existing customers to avoid side effect
Customer.objects.all().delete()
refill_recipe = Recipe(Refilling, amount=10)
cls.notified_users = very_old_subscriber_user.make(_quantity=3)
inactive_date = (
now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1)
)
refill_recipe.make(
customer=(u.customer for u in cls.notified_users),
date=inactive_date,
_quantity=len(cls.notified_users),
)
cls.not_notified_users = [
subscriber_user.make(),
very_old_subscriber_user.make(), # inactive, but account already empty
very_old_subscriber_user.make(), # inactive, but with a recent transaction
very_old_subscriber_user.make(), # inactive, but already warned
]
refill_recipe.make(
customer=cls.not_notified_users[2].customer, date=now() - timedelta(days=1)
)
refill_recipe.make(
customer=cls.not_notified_users[3].customer, date=inactive_date
)
baker.make(
AccountDump,
customer=cls.not_notified_users[3].customer,
dump_operation=None,
)
def test_user_selection(self):
"""Test that the user to warn are well selected."""
users = list(Command._get_users())
assert len(users) == 3
assert set(users) == set(self.notified_users)
def test_command(self):
"""The actual command test."""
call_command("dump_warning_mail")
# 1 already existing + 3 new account dump objects
assert AccountDump.objects.count() == 4
sent_mails = list(mail.outbox)
assert len(sent_mails) == 3
target_emails = {u.email for u in self.notified_users}
for sent in sent_mails:
assert len(sent.to) == 1
assert sent.to[0] in target_emails

View File

@ -16,6 +16,7 @@ import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING
from urllib.parse import parse_qs from urllib.parse import parse_qs
from django import forms from django import forms
@ -49,7 +50,6 @@ from django.views.generic.edit import (
) )
from accounting.models import CurrencyField from accounting.models import CurrencyField
from core.models import User
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from core.views import CanEditMixin, CanViewMixin, TabedViewMixin from core.views import CanEditMixin, CanViewMixin, TabedViewMixin
from core.views.forms import LoginForm from core.views.forms import LoginForm
@ -78,6 +78,9 @@ from counter.models import (
) )
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
if TYPE_CHECKING:
from core.models import User
class CounterAdminMixin(View): class CounterAdminMixin(View):
"""Protect counter admin section.""" """Protect counter admin section."""

File diff suppressed because it is too large Load Diff

View File

@ -37,9 +37,11 @@ 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
from dateutil.relativedelta import relativedelta
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
@ -228,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"]},
}, },
} }
@ -495,6 +504,11 @@ SITH_ECOCUP_LIMIT = 3
# Defines pagination for cash summary # Defines pagination for cash summary
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50 SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
SITH_ACCOUNT_INACTIVITY_DELTA = relativedelta(years=2)
"""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