implement the dump_accounts command

This commit is contained in:
imperosol 2024-11-10 02:55:18 +01:00
parent e712f9fdb8
commit ee9f36d883
8 changed files with 559 additions and 254 deletions

View File

@ -144,6 +144,7 @@ class Command(BaseCommand):
], ],
Counter(name="Eboutic", club=main_club, type="EBOUTIC"), Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"), Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
] ]
Counter.objects.bulk_create(counters) Counter.objects.bulk_create(counters)
bar_groups = [] bar_groups = []

View File

@ -0,0 +1,148 @@
from collections.abc import Iterable
from operator import attrgetter
from django.conf import settings
from django.core.mail import send_mass_mail
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Exists, OuterRef, QuerySet
from django.template.loader import render_to_string
from django.utils.timezone import now
from django.utils.translation import gettext as _
from core.models import User, UserQuerySet
from counter.models import AccountDump, Counter, Customer, Selling
class Command(BaseCommand):
"""Effectively the dump the inactive users.
Users who received a warning mail enough time ago will
have their account emptied, unless they reactivated their
account in the meantime (e.g. by resubscribing).
This command should be automated with a cron task.
"""
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Don't do anything, just display the number of users concerned",
)
def handle(self, *args, **options):
users = self._get_users()
# some users may have resubscribed or performed a purchase
# (which reactivates the account).
# Those reactivated users are not to be mailed about their account dump.
# Instead, the related AccountDump row will be dropped,
# as if nothing ever happened.
# Refunding a user implies a transaction, so refunded users
# count as reactivated users
users_to_dump_qs = users.filter_inactive()
reactivated_users = list(users.difference(users_to_dump_qs))
users_to_dump = list(users_to_dump_qs)
self.stdout.write(
f"{len(reactivated_users)} users have reactivated their account"
)
self.stdout.write(f"{len(users_to_dump)} users will see their account dumped")
if options["dry_run"]:
return
AccountDump.objects.ongoing().filter(
customer__user__in=reactivated_users
).delete()
self._dump_accounts({u.customer for u in users_to_dump})
self._send_mails(users_to_dump)
self.stdout.write("Finished !")
@staticmethod
def _get_users() -> UserQuerySet:
"""Fetch the users which have a pending account dump."""
threshold = now() - settings.SITH_ACCOUNT_DUMP_DELTA
ongoing_dump_operations: QuerySet[AccountDump] = (
AccountDump.objects.ongoing()
.filter(customer__user=OuterRef("pk"), warning_mail_sent_at__lt=threshold)
) # fmt: off
# cf. https://github.com/astral-sh/ruff/issues/14103
return (
User.objects.filter(Exists(ongoing_dump_operations))
.annotate(
warning_date=ongoing_dump_operations.values("warning_mail_sent_at")
)
.select_related("customer")
)
@staticmethod
@transaction.atomic
def _dump_accounts(accounts: set[Customer]):
"""Perform the actual db operations to dump the accounts.
An account dump completion is a two steps process:
- create a special sale which price is equal
to the money in the account
- update the pending account dump operation
by linking it to the aforementioned sale
Args:
accounts: the customer accounts which must be emptied
"""
# Dump operations are special sales,
# which price is equal to the money the user has.
# They are made in a special counter (which should belong to the AE).
# However, they are not linked to a product, because it would
# make no sense to have a product without price.
customer_ids = [account.pk for account in accounts]
pending_dumps: list[AccountDump] = list(
AccountDump.objects.ongoing()
.filter(customer_id__in=customer_ids)
.order_by("customer_id")
)
if len(pending_dumps) != len(customer_ids):
raise ValueError("One or more accounts were not engaged in a dump process")
counter = Counter.objects.get(pk=settings.SITH_COUNTER_ACCOUNT_DUMP_ID)
sales = Selling.objects.bulk_create(
[
Selling(
label="Vidange compte inactif",
club=counter.club,
counter=counter,
seller=None,
product=None,
customer=account,
quantity=1,
unit_price=account.amount,
date=now(),
is_validated=True,
)
for account in accounts
]
)
sales.sort(key=attrgetter("customer_id"))
# dumps and sales are linked to the same customers
# and or both ordered with the same key, so zipping them is valid
for dump, sale in zip(pending_dumps, sales):
dump.dump_operation = sale
AccountDump.objects.bulk_update(pending_dumps, ["dump_operation"])
# Because the sales were created with a bull_create,
# the account amounts haven't been updated,
# which mean we must do it explicitly
Customer.objects.filter(pk__in=customer_ids).update(amount=0)
@staticmethod
def _send_mails(users: Iterable[User]):
"""Send the mails informing users that their account has been dumped."""
mails = [
(
_("Your AE account has been emptied"),
render_to_string("counter/mails/account_dump.jinja", {"user": user}),
settings.DEFAULT_FROM_EMAIL,
[user.email],
)
for user in users
]
send_mass_mail(mails)

View File

@ -5,12 +5,12 @@ from smtplib import SMTPException
from django.conf import settings from django.conf import settings
from django.core.mail import send_mail from django.core.mail import send_mail
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.models import Exists, OuterRef, QuerySet, Subquery from django.db.models import Exists, OuterRef, Subquery
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.timezone import localdate, now from django.utils.timezone import localdate, now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.models import User from core.models import User, UserQuerySet
from counter.models import AccountDump from counter.models import AccountDump
from subscription.models import Subscription from subscription.models import Subscription
@ -72,7 +72,7 @@ class Command(BaseCommand):
self.stdout.write("Finished !") self.stdout.write("Finished !")
@staticmethod @staticmethod
def _get_users() -> QuerySet[User]: def _get_users() -> UserQuerySet:
ongoing_dump_operation = AccountDump.objects.ongoing().filter( ongoing_dump_operation = AccountDump.objects.ongoing().filter(
customer__user=OuterRef("pk") customer__user=OuterRef("pk")
) )
@ -97,7 +97,7 @@ class Command(BaseCommand):
True if the mail was successfully sent, else False True if the mail was successfully sent, else False
""" """
message = render_to_string( message = render_to_string(
"counter/account_dump_warning_mail.jinja", "counter/mails/account_dump_warning.jinja",
{ {
"balance": user.customer.amount, "balance": user.customer.amount,
"last_subscription_date": user.last_subscription_date, "last_subscription_date": user.last_subscription_date,

View File

@ -0,0 +1,22 @@
{% trans %}Hello{% endtrans %},
{% trans trimmed amount=user.customer.amount, date=user.warning_date|date(DATETIME_FORMAT) -%}
Following the email we sent you on {{ date }},
the money of your AE account ({{ amount }} €) has been recovered by the AE.
{%- endtrans %}
{% trans trimmed -%}
If you think this was a mistake, please mail us at ae@utbm.fr.
{%- endtrans %}
{% trans trimmed -%}
Please mind that this is not a closure of your account.
You can still access it via the AE website.
You are also still able to renew your subscription.
{%- endtrans %}
{% trans %}Sincerely{% endtrans %},
L'association des étudiants de l'UTBM
6, Boulevard Anatole France
90000 Belfort

View File

@ -1,5 +1,8 @@
from collections.abc import Iterable
from datetime import timedelta from datetime import timedelta
import freezegun
import pytest
from django.conf import settings from django.conf import settings
from django.core import mail from django.core import mail
from django.core.management import call_command from django.core.management import call_command
@ -9,25 +12,29 @@ from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from core.baker_recipes import subscriber_user, very_old_subscriber_user from core.baker_recipes import subscriber_user, very_old_subscriber_user
from counter.management.commands.dump_warning_mail import Command from counter.management.commands.dump_accounts import Command as DumpCommand
from counter.models import AccountDump, Customer, Refilling from counter.management.commands.dump_warning_mail import Command as WarningCommand
from counter.models import AccountDump, Customer, Refilling, Selling
from subscription.models import Subscription
class TestAccountDumpWarningMailCommand(TestCase): class TestAccountDump(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def set_up_notified_users(cls):
# delete existing customers to avoid side effect """Create the users which should be considered as dumpable"""
Customer.objects.all().delete()
refill_recipe = Recipe(Refilling, amount=10)
cls.notified_users = very_old_subscriber_user.make(_quantity=3) cls.notified_users = very_old_subscriber_user.make(_quantity=3)
inactive_date = ( baker.make(
now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1) Refilling,
) amount=10,
refill_recipe.make(
customer=(u.customer for u in cls.notified_users), customer=(u.customer for u in cls.notified_users),
date=inactive_date, date=now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1),
_quantity=len(cls.notified_users), _quantity=len(cls.notified_users),
) )
@classmethod
def set_up_not_notified_users(cls):
"""Create the users which should not be considered as dumpable"""
refill_recipe = Recipe(Refilling, amount=10)
cls.not_notified_users = [ cls.not_notified_users = [
subscriber_user.make(), subscriber_user.make(),
very_old_subscriber_user.make(), # inactive, but account already empty very_old_subscriber_user.make(), # inactive, but account already empty
@ -38,7 +45,8 @@ class TestAccountDumpWarningMailCommand(TestCase):
customer=cls.not_notified_users[2].customer, date=now() - timedelta(days=1) customer=cls.not_notified_users[2].customer, date=now() - timedelta(days=1)
) )
refill_recipe.make( refill_recipe.make(
customer=cls.not_notified_users[3].customer, date=inactive_date customer=cls.not_notified_users[3].customer,
date=now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1),
) )
baker.make( baker.make(
AccountDump, AccountDump,
@ -46,10 +54,19 @@ class TestAccountDumpWarningMailCommand(TestCase):
dump_operation=None, dump_operation=None,
) )
class TestAccountDumpWarningMailCommand(TestAccountDump):
@classmethod
def setUpTestData(cls):
# delete existing accounts to avoid side effect
Customer.objects.all().delete()
cls.set_up_notified_users()
cls.set_up_not_notified_users()
def test_user_selection(self): def test_user_selection(self):
"""Test that the user to warn are well selected.""" """Test that the user to warn are well selected."""
users = list(Command._get_users()) users = list(WarningCommand._get_users())
assert len(users) == 3 assert len(users) == len(self.notified_users)
assert set(users) == set(self.notified_users) assert set(users) == set(self.notified_users)
def test_command(self): def test_command(self):
@ -63,3 +80,89 @@ class TestAccountDumpWarningMailCommand(TestCase):
for sent in sent_mails: for sent in sent_mails:
assert len(sent.to) == 1 assert len(sent.to) == 1
assert sent.to[0] in target_emails assert sent.to[0] in target_emails
class TestAccountDumpCommand(TestAccountDump):
@classmethod
def setUpTestData(cls):
with freezegun.freeze_time(
now() - settings.SITH_ACCOUNT_DUMP_DELTA - timedelta(hours=1)
):
# pretend the notifications happened enough time ago
# to make sure the accounts are dumpable right now
cls.set_up_notified_users()
AccountDump.objects.bulk_create(
[
AccountDump(customer=u.customer, warning_mail_sent_at=now())
for u in cls.notified_users
]
)
# One of the users reactivated its account
baker.make(
Subscription,
member=cls.notified_users[0],
subscription_start=now() - timedelta(days=1),
)
def assert_accounts_dumped(self, accounts: Iterable[Customer]):
"""Assert that the given accounts have been dumped"""
assert not (
AccountDump.objects.ongoing().filter(customer__in=accounts).exists()
)
for customer in accounts:
initial_amount = customer.amount
customer.refresh_from_db()
assert customer.amount == 0
operation: Selling = customer.buyings.order_by("date").last()
assert operation.unit_price == initial_amount
assert operation.counter_id == settings.SITH_COUNTER_ACCOUNT_DUMP_ID
assert operation.is_validated is True
dump = customer.dumps.last()
assert dump.dump_operation == operation
def test_user_selection(self):
"""Test that users to dump are well selected"""
# even reactivated users should be selected,
# because their pending AccountDump must be dealt with
users = list(DumpCommand._get_users())
assert len(users) == len(self.notified_users)
assert set(users) == set(self.notified_users)
def test_dump_accounts(self):
"""Test the _dump_accounts method"""
# the first user reactivated its account, thus should not be dumped
to_dump: set[Customer] = {u.customer for u in self.notified_users[1:]}
DumpCommand._dump_accounts(to_dump)
self.assert_accounts_dumped(to_dump)
def test_dump_account_with_active_users(self):
"""Test that the dump account method failed if given active users."""
active_user = subscriber_user.make()
active_user.customer.amount = 10
active_user.customer.save()
customers = {u.customer for u in self.notified_users}
customers.add(active_user.customer)
with pytest.raises(ValueError):
DumpCommand._dump_accounts(customers)
for customer in customers:
# all users should have kept their money
initial_amount = customer.amount
customer.refresh_from_db()
assert customer.amount == initial_amount
def test_command(self):
"""test the actual command"""
call_command("dump_accounts")
reactivated_user = self.notified_users[0]
# the pending operation should be deleted for reactivated users
assert not reactivated_user.customer.dumps.exists()
assert reactivated_user.customer.amount == 10
dumped_users = self.notified_users[1:]
self.assert_accounts_dumped([u.customer for u in dumped_users])
sent_mails = list(mail.outbox)
assert len(sent_mails) == 2
target_emails = {u.email for u in dumped_users}
for sent in sent_mails:
assert len(sent.to) == 1
assert sent.to[0] in target_emails

File diff suppressed because it is too large Load Diff

View File

@ -370,6 +370,8 @@ SITH_CLUB_REFOUND_ID = 89
SITH_COUNTER_REFOUND_ID = 38 SITH_COUNTER_REFOUND_ID = 38
SITH_PRODUCT_REFOUND_ID = 5 SITH_PRODUCT_REFOUND_ID = 5
SITH_COUNTER_ACCOUNT_DUMP_ID = 39
# Pages # Pages
SITH_CORE_PAGE_SYNTAX = "Aide_sur_la_syntaxe" SITH_CORE_PAGE_SYNTAX = "Aide_sur_la_syntaxe"