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 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.stdout.write("Accounts dumped") nb_successful_mails = self._send_mails(users_to_dump) self.stdout.write(f"{nb_successful_mails} were successfuly sent.") 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) seller = User.objects.get(pk=settings.SITH_ROOT_USER_ID) sales = Selling.objects.bulk_create( [ Selling( label="Vidange compte inactif", club=counter.club, counter=counter, seller=seller, 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]) -> int: """Send the mails informing users that their account has been dumped. Returns: The number of emails successfully sent. """ 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 ] return send_mass_mail(mails, fail_silently=True)