Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Include free plans for api use notifications #4204

Merged
merged 9 commits into from
Jun 20, 2024
71 changes: 45 additions & 26 deletions api/organisations/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@


@register_task_handler()
def send_org_over_limit_alert(organisation_id) -> None:
def send_org_over_limit_alert(organisation_id: int) -> None:
organisation = Organisation.objects.get(id=organisation_id)

subscription_metadata = get_subscription_metadata(organisation)
Expand Down Expand Up @@ -150,18 +150,34 @@


def _handle_api_usage_notifications(organisation: Organisation) -> None:
subscription_cache = organisation.subscription_information_cache
billing_starts_at = subscription_cache.current_billing_term_starts_at
now = timezone.now()

# Truncate to the closest active month to get start of current period.
month_delta = relativedelta(now, billing_starts_at).months
period_starts_at = relativedelta(months=month_delta) + billing_starts_at
if organisation.subscription.is_free_plan:
allowed_api_calls = organisation.subscription.max_api_calls
# Default to a rolling month for free accounts
days = 30
period_starts_at = now - timedelta(days)
elif not organisation.has_subscription_information_cache:
# Since the calling code is a list of many organisations
# log the error and return without raising an exception.
logger.error(

Check warning on line 163 in api/organisations/tasks.py

View check run for this annotation

Codecov / codecov/patch

api/organisations/tasks.py#L163

Added line #L163 was not covered by tests
f"Paid organisation {organisation.id} is missing subscription information cache"
)
return

Check warning on line 166 in api/organisations/tasks.py

View check run for this annotation

Codecov / codecov/patch

api/organisations/tasks.py#L166

Added line #L166 was not covered by tests
else:
subscription_cache = organisation.subscription_information_cache
billing_starts_at = subscription_cache.current_billing_term_starts_at

# Truncate to the closest active month to get start of current period.
month_delta = relativedelta(now, billing_starts_at).months
period_starts_at = relativedelta(months=month_delta) + billing_starts_at

days = relativedelta(now, period_starts_at).days
allowed_api_calls = subscription_cache.allowed_30d_api_calls

days = relativedelta(now, period_starts_at).days
api_usage = get_current_api_usage(organisation.id, f"-{days}d")

api_usage_percent = int(100 * api_usage / subscription_cache.allowed_30d_api_calls)
api_usage_percent = int(100 * api_usage / allowed_api_calls)

matched_threshold = None
for threshold in API_USAGE_ALERT_THRESHOLDS:
Expand All @@ -176,7 +192,7 @@

if OrganisationAPIUsageNotification.objects.filter(
notified_at__gt=period_starts_at,
percent_usage=matched_threshold,
percent_usage__gte=matched_threshold,
).exists():
# Already sent the max notification level so don't resend.
return
Expand All @@ -187,10 +203,7 @@
def handle_api_usage_notifications() -> None:
flagsmith_client = get_client("local", local_eval=True)

for organisation in Organisation.objects.filter(
subscription_information_cache__current_billing_term_starts_at__isnull=False,
subscription_information_cache__current_billing_term_ends_at__isnull=False,
).select_related(
for organisation in Organisation.objects.all().select_related(
"subscription_information_cache",
):
feature_enabled = flagsmith_client.get_identity_flags(
Expand Down Expand Up @@ -234,21 +247,27 @@
).values_list("organisation_id", flat=True)
)

for organisation in Organisation.objects.filter(
id__in=organisation_ids,
subscription_information_cache__current_billing_term_ends_at__lte=closing_billing_term,
subscription_information_cache__current_billing_term_ends_at__gte=now,
subscription_information_cache__current_billing_term_starts_at__lte=F(
"subscription_information_cache__current_billing_term_ends_at"
for organisation in (
Organisation.objects.filter(
id__in=organisation_ids,
subscription_information_cache__current_billing_term_ends_at__lte=closing_billing_term,
subscription_information_cache__current_billing_term_ends_at__gte=now,
subscription_information_cache__current_billing_term_starts_at__lte=F(
"subscription_information_cache__current_billing_term_ends_at"
)
- month_window_start,
subscription_information_cache__current_billing_term_starts_at__gte=F(
"subscription_information_cache__current_billing_term_ends_at"
)
- month_window_end,
)
- month_window_start,
subscription_information_cache__current_billing_term_starts_at__gte=F(
"subscription_information_cache__current_billing_term_ends_at"
.exclude(
subscription__plan=FREE_PLAN_ID,
)
.select_related(
"subscription_information_cache",
"subscription",
)
- month_window_end,
).select_related(
"subscription_information_cache",
"subscription",
):
subscription_cache = organisation.subscription_information_cache
api_usage = get_current_api_usage(organisation.id, "30d")
Expand Down
93 changes: 93 additions & 0 deletions api/tests/unit/organisations/test_unit_organisations_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
FREE_PLAN_ID,
MAX_API_CALLS_IN_FREE_PLAN,
MAX_SEATS_IN_FREE_PLAN,
SCALE_UP,
)
from organisations.subscriptions.xero.metadata import XeroSubscriptionMetadata
from organisations.tasks import (
Expand Down Expand Up @@ -290,6 +291,8 @@ def test_handle_api_usage_notifications_below_100(
) -> None:
# Given
now = timezone.now()
organisation.subscription.plan = SCALE_UP
organisation.subscription.save()
OrganisationSubscriptionInformationCache.objects.create(
organisation=organisation,
allowed_seats=10,
Expand Down Expand Up @@ -382,6 +385,8 @@ def test_handle_api_usage_notifications_above_100(
) -> None:
# Given
now = timezone.now()
organisation.subscription.plan = SCALE_UP
organisation.subscription.save()
OrganisationSubscriptionInformationCache.objects.create(
organisation=organisation,
allowed_seats=10,
Expand Down Expand Up @@ -469,6 +474,94 @@ def test_handle_api_usage_notifications_above_100(
assert OrganisationAPIUsageNotification.objects.first() == api_usage_notification


@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00")
def test_handle_api_usage_notifications_for_free_accounts(
mocker: MockerFixture,
organisation: Organisation,
mailoutbox: list[EmailMultiAlternatives],
) -> None:
# Given
assert organisation.subscription.is_free_plan
assert organisation.subscription.max_api_calls == MAX_API_CALLS_IN_FREE_PLAN

mock_api_usage = mocker.patch(
"organisations.tasks.get_current_api_usage",
)
mock_api_usage.return_value = MAX_API_CALLS_IN_FREE_PLAN + 5_000

get_client_mock = mocker.patch("organisations.tasks.get_client")
client_mock = MagicMock()
get_client_mock.return_value = client_mock
client_mock.get_identity_flags.return_value.is_feature_enabled.return_value = True

assert not OrganisationAPIUsageNotification.objects.filter(
organisation=organisation,
).exists()

# When
handle_api_usage_notifications()

# Then
mock_api_usage.assert_called_once_with(organisation.id, "-30d")

assert len(mailoutbox) == 1
email = mailoutbox[0]
assert email.subject == "Flagsmith API use has reached 100%"
assert email.body == (
"Hi there,\n\nThe API usage for Test Org has breached "
"100% within the current subscription period. Please "
"upgrade your organisations account to ensure "
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"upgrade your organisations account to ensure "
"upgrade your organisation's account to ensure "

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok no problem. I've fixed the other invocations as well in the other tests.

"continued service.\n\nThank you!\n\n"
"The Flagsmith Team\n"
)

assert len(email.alternatives) == 1
assert len(email.alternatives[0]) == 2
assert email.alternatives[0][1] == "text/html"

assert email.alternatives[0][0] == (
"<table>\n\n <tr>\n\n <td>Hi "
"there,</td>\n\n </tr>\n\n <tr>\n\n "
" <td>\n The API usage for Test Org "
"has breached\n 100% within the "
"current subscription period.\n "
"Please upgrade your organisations account to ensure "
"continued service.\n </td>\n\n\n "
" </tr>\n\n <tr>\n\n <td>"
"Thank you!</td>\n\n </tr>\n\n <tr>\n\n"
" <td>The Flagsmith Team</td>\n\n "
"</tr>\n\n</table>\n"
)

assert email.from_email == "[email protected]"
# Extra staff included because threshold is over 100.
assert email.to == ["[email protected]", "[email protected]"]

assert (
OrganisationAPIUsageNotification.objects.filter(
organisation=organisation,
).count()
== 1
)
api_usage_notification = OrganisationAPIUsageNotification.objects.filter(
organisation=organisation,
).first()

assert api_usage_notification.percent_usage == 100

# Now re-run the usage to make sure the notification isn't resent.
handle_api_usage_notifications()

assert (
OrganisationAPIUsageNotification.objects.filter(
organisation=organisation,
).count()
== 1
)

assert OrganisationAPIUsageNotification.objects.first() == api_usage_notification


@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00")
def test_charge_for_api_call_count_overages_scale_up(
organisation: Organisation,
Expand Down
Loading