mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-22 14:13:21 +00:00
implement the dump_accounts
command
This commit is contained in:
parent
e712f9fdb8
commit
ee9f36d883
@ -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 = []
|
||||||
|
148
counter/management/commands/dump_accounts.py
Normal file
148
counter/management/commands/dump_accounts.py
Normal 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)
|
@ -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,
|
||||||
|
22
counter/templates/counter/mails/account_dump.jinja
Normal file
22
counter/templates/counter/mails/account_dump.jinja
Normal 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
|
@ -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
@ -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"
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user