Skip to content

Commit

Permalink
feat(sponsorship): Move logic to send email out of the helper to upda…
Browse files Browse the repository at this point in the history
…te the current_amount
  • Loading branch information
ERosendo committed Jan 23, 2024
1 parent f6117d2 commit 826dd77
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 121 deletions.
18 changes: 0 additions & 18 deletions bc/channel/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,3 @@ def get_channel_groups_per_user(user_pk: int) -> QuerySet[Group]:
)
.all()
)


def get_groups_with_low_funding() -> QuerySet[Group]:
"""Queries all groups with total funding left below a specified threshold
Returns:
QuerySet[Group]: list of groups with low funding
"""
return (
Group.objects.prefetch_related("sponsorships")
.annotate(
total_funding=Sum(
"sponsorships__current_amount",
filter=Q(sponsorships__current_amount__gte=3.00),
)
)
.filter(total_funding__lte=settings.LOW_FUNDING_EMAIL_THRESHOLD)
)
46 changes: 0 additions & 46 deletions bc/channel/tests/test_selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
get_all_enabled_channels,
get_channel_groups_per_user,
get_channels_per_subscription,
get_groups_with_low_funding,
)
from bc.sponsorship.tests.factories import SponsorshipFactory
from bc.subscription.tests.factories import SubscriptionFactory
Expand Down Expand Up @@ -73,48 +72,3 @@ def test_can_get_groups_linked_to_users(self):
for group in groups:
# check the number of channels per group
self.assertEqual(group.channels.count(), 1)


class GetGroupsWithLowFunding(TestCase):
group_1 = None
group_2 = None

@classmethod
def setUpTestData(cls) -> None:
# Create 2 sponsorships and update the current amount
old_sponsorship_1 = SponsorshipFactory()
old_sponsorship_1.current_amount = 10.0
old_sponsorship_1.save()

old_sponsorship_2 = SponsorshipFactory()
old_sponsorship_2.current_amount = 20.0
old_sponsorship_2.save()

# Create 2 channel with a sponsorship
cls.group_1 = GroupFactory(sponsorships=[old_sponsorship_1])
cls.group_2 = GroupFactory(sponsorships=[old_sponsorship_2])

def test_can_get_groups_with_low_funding(self):
"""Can we get groups with low funding"""
low_funding_groups = get_groups_with_low_funding()
self.assertEqual(low_funding_groups.count(), 2)

# Add a big sponsorship for one of the groups
sponsorship = SponsorshipFactory(original_amount=400)
self.group_1.sponsorships.add(sponsorship)

low_funding_groups = get_groups_with_low_funding()
self.assertEqual(low_funding_groups.count(), 1)
self.assertEqual(
list(low_funding_groups.values_list("id", flat=True)),
[self.group_2.pk],
)

def test_exclude_groups_with_no_sponsorship(self):
"""Can the query exclude groups with no sponsorships"""
# Create six channels with no sponsorship
GroupFactory.create_batch(6)

# Get the list of groups with low funding
low_funding_groups = get_groups_with_low_funding()
self.assertEqual(low_funding_groups.count(), 2)
45 changes: 42 additions & 3 deletions bc/sponsorship/services.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
from decimal import Decimal

from django.conf import settings
from django.db.models import QuerySet
from django_rq.queues import get_queue
from rq import Retry

from bc.channel.models import Group
from bc.sponsorship.selectors import get_sponsorships_for_subscription
from bc.sponsorship.utils import (
get_email_threshold_index,
send_low_fund_email_to_curators,
)
from bc.subscription.types import Document

from .models import Transaction

queue = get_queue("default")


def log_purchase(
sponsored_groups: QuerySet[Group], subscription_pk: int, document: Document
Expand All @@ -32,12 +43,40 @@ def log_purchase(
)

for sponsorship in sponsorships:
document_cost = Decimal(
document.get_price()
* sponsorship.groups.count()
/ sponsored_groups.count()
)

threshold_idx = get_email_threshold_index(sponsorship.current_amount)
threshold_value = (
settings.LOW_FUNDING_EMAIL_THRESHOLDS[threshold_idx]
if threshold_idx is not None
else None
)

if (
threshold_value
and threshold_value > sponsorship.current_amount - document_cost
):
group = sponsorship.groups.first()
queue.enqueue(
send_low_fund_email_to_curators,
threshold_idx,
group.pk, # type: ignore
sponsorship.pk,
sponsorship.current_amount - Decimal(document_cost),
retry=Retry(
max=settings.RQ_MAX_NUMBER_OF_RETRIES,
interval=settings.RQ_RETRY_INTERVAL,
),
)

Transaction.objects.create(
user=sponsorship.user,
sponsorship=sponsorship,
type=Transaction.DOCUMENT_PURCHASE,
amount=document.get_price()
* sponsorship.groups.count()
/ sponsored_groups.count(),
amount=document_cost,
note=document.get_note(),
)
65 changes: 59 additions & 6 deletions bc/sponsorship/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from django.core import mail
from django.core.cache import cache
from django.test import TestCase
from django.test import TestCase, override_settings

from bc.channel.selectors import get_sponsored_groups_per_subscription
from bc.channel.tests.factories import ChannelFactory, GroupFactory
Expand Down Expand Up @@ -133,13 +133,14 @@ def test_transaction_updates_sponsorship_current_amount(self):
round(self.act_sponsorship_2.original_amount - Decimal(2 / 3), 2),
)

@override_settings(LOW_FUNDING_EMAIL_THRESHOLDS=[60.00, 30.00, 3.00])
def test_can_send_low_fund_emails(self):
# Create two curators for channel 1
UserFactory.create_batch(2, channels=[self.channel_1])

# Update the current amount of the sponsorships for group 1
for sponsorship in self.act_sponsorship_1:
sponsorship.current_amount = Decimal(10.00)
sponsorship.current_amount = Decimal(60.00)
sponsorship.save()

# Create one curator for channel 2
Expand All @@ -149,7 +150,7 @@ def test_can_send_low_fund_emails(self):
self.subscription.pk
)
# Adds transaction for sponsorship #1 and #2. Only sponsorship #1
# will trigger logic to send email
# will trigger logic to send the first alert
with self.captureOnCommitCallbacks(execute=True):
log_purchase(
sponsored_groups,
Expand All @@ -159,13 +160,19 @@ def test_can_send_low_fund_emails(self):

self.assertEqual(len(mail.outbox), 1)
self.assertIn(self.group_1.name, mail.outbox[0].body)
self.assertIn("[Action Needed]:", mail.outbox[0].subject)

self.act_sponsorship_2.current_amount = 5.00
# reload a model’s values from the database
for sponsorship in self.act_sponsorship_1:
sponsorship.refresh_from_db()

self.act_sponsorship_2.current_amount = Decimal(30.00)
self.act_sponsorship_2.save()

# Adds another transaction for sponsorship #1 and #2. Sponsorship #2
# will trigger email this time. The logic should skip email for
# sponsorship #1 because we already sent one.
# will trigger an alert this time. The logic should skip email for
# sponsorship #1 because the current amount is bigger than the second
# threshold.
with self.captureOnCommitCallbacks(execute=True):
log_purchase(
sponsored_groups,
Expand All @@ -175,3 +182,49 @@ def test_can_send_low_fund_emails(self):

self.assertEqual(len(mail.outbox), 2)
self.assertIn(self.group_2.name, mail.outbox[1].body)
self.assertIn("[Action Needed, 2nd Notice]:", mail.outbox[1].subject)

# reload a model’s values from the database
for sponsorship in self.act_sponsorship_1:
sponsorship.refresh_from_db()
self.act_sponsorship_2.refresh_from_db()

# Adds another transaction for sponsorship #1 and #2. This new
# transaction should not trigger alerts.
with self.captureOnCommitCallbacks(execute=True):
log_purchase(
sponsored_groups,
self.webhook_event.subscription.id,
self.document,
)

self.assertEqual(len(mail.outbox), 2)

@override_settings(LOW_FUNDING_EMAIL_THRESHOLDS=[60.00, 30.00, 3.00])
def test_add_info_mail_to_final_notice_alert(self):
# Create two curators for channel 1
UserFactory.create_batch(2, channels=[self.channel_1])

# Update the current amount of the sponsorship for group 1
sponsorship = self.act_sponsorship_1[0]
sponsorship.current_amount = Decimal(3.10)
sponsorship.save()

sponsored_groups = get_sponsored_groups_per_subscription(
self.subscription.pk
)
# Adds transaction for sponsorship #1 and #2. Only sponsorship #1
# will trigger logic to send the first alert
with self.captureOnCommitCallbacks(execute=True):
log_purchase(
sponsored_groups,
self.webhook_event.subscription.id,
self.document,
)

self.assertEqual(len(mail.outbox), 1)
self.assertIn(self.group_1.name, mail.outbox[0].body)
self.assertIn("[Action Needed, Final Notice]:", mail.outbox[0].subject)
# check the number of email address in the “Bcc” header
self.assertEqual(3, len(mail.outbox[0].bcc))
self.assertIn("[email protected]", mail.outbox[0].bcc)
53 changes: 5 additions & 48 deletions bc/sponsorship/utils.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
from decimal import Decimal

from django.conf import settings
from django.core.cache import cache
from django.core.mail import EmailMessage
from django.db import transaction
from django.db.models import F
from django_rq.queues import get_queue
from rq import Retry

from bc.channel.models import Group
from bc.channel.selectors import get_groups_with_low_funding
from bc.sponsorship.emails import emails
from bc.sponsorship.models import Sponsorship
from bc.users.selectors import get_curators_by_channel_group_id
from bc.users.utils.email import EmailType

from .models import Transaction

queue = get_queue("default")


def get_email_threshold_index(current_amount: Decimal) -> int | None:
"""
Expand Down Expand Up @@ -131,49 +124,13 @@ def update_sponsorships_current_amount(ledger_entry: Transaction) -> None:
This method updates the current_amount field of the sponsorship record
related to the given ledger entry after a document has been purchased.
It also schedules a task to send an alert when the funds of a group are
running low.
Args:
ledger_entry (Transaction): The transaction object related to the purchase.
"""
if not ledger_entry.sponsorship:
return None

# lock sponsorship record while updating the current_value and
# checking whether we should send a low fund email
with transaction.atomic():
sponsorship = Sponsorship.objects.select_for_update().get(
pk=ledger_entry.sponsorship_id
)
sponsorship.current_amount = F("current_amount") - Decimal(
ledger_entry.amount
)
sponsorship.save()

channel_group_ids = list(
sponsorship.groups.all().values_list("id", flat=True)
)
low_funding_query = get_groups_with_low_funding()
low_funding_groups = low_funding_query.filter(pk__in=channel_group_ids)

if not low_funding_groups.exists():
return None

for group in low_funding_groups:
# try to get cache key for the group
cache_key = f"low-funding:{group.pk}"
already_notified_group = cache.get(cache_key)

# skip the group if the key is still in cache
if already_notified_group:
continue

queue.enqueue(
send_low_fund_email_to_curators,
group.pk,
retry=Retry(
max=settings.RQ_MAX_NUMBER_OF_RETRIES,
interval=settings.RQ_RETRY_INTERVAL,
),
)
sponsorship = ledger_entry.sponsorship
sponsorship.current_amount = F("current_amount") - Decimal(
ledger_entry.amount
)
sponsorship.save()

0 comments on commit 826dd77

Please sign in to comment.