"""Emailing a club's admins about money it owes the platform. Built on the same BillingNotice the on-screen banner uses (notices.py), so the email and the banner can never disagree about how much is owed or how long is left. **Sent once per escalation level, not once per run.** The command is on a daily cron; a club that owes money for a month must not receive thirty identical emails. ``Due.last_reminder_level`` records the level last mailed, so an escalation (info -> warning -> error) always gets through and a repeat of the same level never does. """ from dataclasses import dataclass from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils import timezone from django.utils.translation import gettext as _ from billing.services.notices import BillingNotice, club_billing_notice from club.models import ClubRole @dataclass(frozen=True) class ReminderResult: club: object notice: BillingNotice recipients: list[str] sent: bool skipped_reason: str = "" def admin_emails(club) -> list[str]: """Every club admin we can actually reach, de-duplicated and order-stable. A club with admins but no email addresses returns empty — the caller reports that rather than silently counting it as reminded. """ roles = ClubRole.objects.filter(club=club, role=ClubRole.Roles.ADMIN).select_related("member", "member__user").order_by("member__last_name", "member__first_name") seen, emails = set(), [] for role in roles: email = role.member.contact_email if email and email not in seen: seen.add(email) emails.append(email) return emails def needs_reminder(due, notice: BillingNotice) -> bool: """True when this due has not yet been mailed at its current level.""" return due.last_reminder_level != notice.level def send_reminder(club, notice: BillingNotice, *, recipients: list[str]) -> None: """Render and send one reminder, then record the level so it is not repeated.""" context = { "club": club, "notice": notice, "due": notice.due, "billing_contact": settings.BILLING_CONTACT_EMAIL, } subject = render_to_string("billing/email/reminder_subject.txt", context).strip() text_body = render_to_string("billing/email/reminder.txt", context) message = EmailMultiAlternatives(subject=subject, body=text_body, from_email=settings.DEFAULT_FROM_EMAIL, to=recipients) message.send(fail_silently=False) notice.due.last_reminder_level = notice.level notice.due.last_reminder_sent_at = timezone.now() notice.due.save(update_fields=["last_reminder_level", "last_reminder_sent_at", "modified"]) def reminders_to_send(clubs, today=None, *, force: bool = False) -> list[ReminderResult]: """Work out who would be reminded, without sending anything. Returned whether or not each one is actually sendable, so the command can report a club with no reachable admin instead of skipping it in silence — an unreachable club is exactly the one that gets archived without ever having been told. """ results = [] for club in clubs: notice = club_billing_notice(club, today) if notice is None: continue recipients = admin_emails(club) if not recipients: results.append(ReminderResult(club=club, notice=notice, recipients=[], sent=False, skipped_reason=_("no club admin with an email address"))) continue if not force and not needs_reminder(notice.due, notice): results.append(ReminderResult(club=club, notice=notice, recipients=recipients, sent=False, skipped_reason=_("already reminded at this level"))) continue results.append(ReminderResult(club=club, notice=notice, recipients=recipients, sent=True)) return results