Skip to content

Commit

Permalink
implement the dump_accounts command
Browse files Browse the repository at this point in the history
  • Loading branch information
imperosol committed Nov 13, 2024
1 parent 1e08470 commit 4c9963f
Show file tree
Hide file tree
Showing 8 changed files with 660 additions and 350 deletions.
1 change: 1 addition & 0 deletions core/management/commands/populate.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ def handle(self, *args, **options):
],
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
]
Counter.objects.bulk_create(counters)
bar_groups = []
Expand Down
148 changes: 148 additions & 0 deletions counter/management/commands/dump_accounts.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 4 additions & 4 deletions counter/management/commands/dump_warning_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
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.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 core.models import User, UserQuerySet
from counter.models import AccountDump
from subscription.models import Subscription

Expand Down Expand Up @@ -72,7 +72,7 @@ def handle(self, *args, **options):
self.stdout.write("Finished !")

@staticmethod
def _get_users() -> QuerySet[User]:
def _get_users() -> UserQuerySet:
ongoing_dump_operation = AccountDump.objects.ongoing().filter(
customer__user=OuterRef("pk")
)
Expand All @@ -97,7 +97,7 @@ def _send_mail(self, user: User) -> bool:
True if the mail was successfully sent, else False
"""
message = render_to_string(
"counter/account_dump_warning_mail.jinja",
"counter/mails/account_dump_warning.jinja",
{
"balance": user.customer.amount,
"last_subscription_date": user.last_subscription_date,
Expand Down
22 changes: 22 additions & 0 deletions counter/templates/counter/mails/account_dump.jinja
Original file line number Diff line number Diff line change
@@ -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
133 changes: 118 additions & 15 deletions counter/tests/test_account_dump.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from collections.abc import Iterable
from datetime import timedelta

import freezegun
import pytest
from django.conf import settings
from django.core import mail
from django.core.management import call_command
Expand All @@ -9,25 +12,29 @@
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
from counter.management.commands.dump_accounts import Command as DumpCommand
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
def setUpTestData(cls):
# delete existing customers to avoid side effect
Customer.objects.all().delete()
refill_recipe = Recipe(Refilling, amount=10)
def set_up_notified_users(cls):
"""Create the users which should be considered as dumpable"""
cls.notified_users = very_old_subscriber_user.make(_quantity=3)
inactive_date = (
now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1)
)
refill_recipe.make(
baker.make(
Refilling,
amount=10,
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),
)

@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 = [
subscriber_user.make(),
very_old_subscriber_user.make(), # inactive, but account already empty
Expand All @@ -38,18 +45,28 @@ def setUpTestData(cls):
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
customer=cls.not_notified_users[3].customer,
date=now() - settings.SITH_ACCOUNT_INACTIVITY_DELTA - timedelta(days=1),
)
baker.make(
AccountDump,
customer=cls.not_notified_users[3].customer,
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):
"""Test that the user to warn are well selected."""
users = list(Command._get_users())
assert len(users) == 3
users = list(WarningCommand._get_users())
assert len(users) == len(self.notified_users)
assert set(users) == set(self.notified_users)

def test_command(self):
Expand All @@ -63,3 +80,89 @@ def test_command(self):
for sent in sent_mails:
assert len(sent.to) == 1
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
Loading

0 comments on commit 4c9963f

Please sign in to comment.