From 16a24685449ea33906c5b225929c1ad08717ca70 Mon Sep 17 00:00:00 2001 From: Zach Aysan Date: Thu, 15 Feb 2024 11:29:14 -0500 Subject: [PATCH] feat: Create api usage function (#3340) --- api/app_analytics/influxdb_wrapper.py | 41 +++- api/organisations/chargebee/serializers.py | 1 + .../chargebee/webhook_handlers.py | 38 +++- api/organisations/constants.py | 5 + .../0051_create_org_api_usage_notification.py | 36 ++++ api/organisations/models.py | 17 ++ api/organisations/tasks.py | 117 +++++++++++- .../organisations/api_usage_notification.html | 32 ++++ .../organisations/api_usage_notification.txt | 7 + .../api_usage_notification_limit.html | 32 ++++ .../api_usage_notification_limit.txt | 7 + .../test_unit_organisations_tasks.py | 177 ++++++++++++++++++ .../test_unit_organisations_views.py | 8 + frontend/web/components/OrganisationUsage.tsx | 4 +- 14 files changed, 506 insertions(+), 16 deletions(-) create mode 100644 api/organisations/constants.py create mode 100644 api/organisations/migrations/0051_create_org_api_usage_notification.py create mode 100644 api/organisations/templates/organisations/api_usage_notification.html create mode 100644 api/organisations/templates/organisations/api_usage_notification.txt create mode 100644 api/organisations/templates/organisations/api_usage_notification_limit.html create mode 100644 api/organisations/templates/organisations/api_usage_notification_limit.txt diff --git a/api/app_analytics/influxdb_wrapper.py b/api/app_analytics/influxdb_wrapper.py index 9fd2d3fbf3c1..1779bfc4739c 100644 --- a/api/app_analytics/influxdb_wrapper.py +++ b/api/app_analytics/influxdb_wrapper.py @@ -88,7 +88,6 @@ def influx_query_manager( f" |> drop(columns: {drop_columns_input})" f"{extra}" ) - logger.debug("Running query in influx: \n\n %s", query) try: @@ -314,6 +313,46 @@ def get_top_organisations(date_range: str, limit: str = ""): return dataset +def get_current_api_usage(organisation_id: int, date_range: str) -> int: + """ + Query influx db for api usage + + :param organisation_id: filtered organisation + :param date_range: data range for current api usage window + + :return: number of current api calls + """ + + bucket = read_bucket + results = InfluxDBWrapper.influx_query_manager( + date_range=date_range, + bucket=bucket, + filters=build_filter_string( + [ + 'r._measurement == "api_call"', + 'r["_field"] == "request_count"', + f'r["organisation_id"] == "{organisation_id}"', + ] + ), + drop_columns=("_start", "_stop", "_time"), + extra='|> sum() \ + |> group() \ + |> sort(columns: ["_value"], desc: true) ', + ) + + for result in results: + # Return zero if there are no API calls recorded. + if len(result.records) == 0: + return 0 + + # There should only be one matching result due to the + # group part of the query. + assert len(result.records) == 1 + return result.records[0].get_value() + + return 0 + + def build_filter_string(filter_expressions: typing.List[str]) -> str: return "|> ".join( ["", *[f"filter(fn: (r) => {exp})" for exp in filter_expressions]] diff --git a/api/organisations/chargebee/serializers.py b/api/organisations/chargebee/serializers.py index 0e29cf86e9c5..cd87d7ed1a78 100644 --- a/api/organisations/chargebee/serializers.py +++ b/api/organisations/chargebee/serializers.py @@ -33,6 +33,7 @@ class ProcessSubscriptionSubscriptionSerializer(Serializer): id = CharField(allow_null=False) status = CharField(allow_null=False) plan_id = CharField(allow_null=True, required=False, default=None) + current_term_start = IntegerField(required=False, default=None) current_term_end = IntegerField(required=False, default=None) addons = ListField( child=ProcessSubscriptionAddonsSerializer(), required=False, default=list diff --git a/api/organisations/chargebee/webhook_handlers.py b/api/organisations/chargebee/webhook_handlers.py index 71cf6c2b1a48..7c9a66d84588 100644 --- a/api/organisations/chargebee/webhook_handlers.py +++ b/api/organisations/chargebee/webhook_handlers.py @@ -103,7 +103,7 @@ def payment_succeeded(request: Request) -> Response: return Response(status=status.HTTP_200_OK) -def process_subscription(request: Request) -> Response: +def process_subscription(request: Request) -> Response: # noqa: C901 serializer = ProcessSubscriptionSerializer(data=request.data) # Since this function is a catchall, we're not surprised if @@ -145,15 +145,35 @@ def process_subscription(request: Request) -> Response: chargebee_subscription=subscription, customer_email=customer["email"], ) + osic_defaults = { + "chargebee_updated_at": timezone.now(), + "allowed_30d_api_calls": subscription_metadata.api_calls, + "allowed_seats": subscription_metadata.seats, + "organisation_id": existing_subscription.organisation_id, + "allowed_projects": subscription_metadata.projects, + "chargebee_email": subscription_metadata.chargebee_email, + } + + if "current_term_end" in subscription: + current_term_end = subscription["current_term_end"] + if current_term_end is None: + osic_defaults["current_billing_term_ends_at"] = None + else: + osic_defaults["current_billing_term_ends_at"] = datetime.fromtimestamp( + current_term_end + ).replace(tzinfo=timezone.utc) + + if "current_term_start" in subscription: + current_term_start = subscription["current_term_start"] + if current_term_start is None: + osic_defaults["current_billing_term_starts_at"] = None + else: + osic_defaults["current_billing_term_starts_at"] = datetime.fromtimestamp( + current_term_start + ).replace(tzinfo=timezone.utc) + OrganisationSubscriptionInformationCache.objects.update_or_create( organisation_id=existing_subscription.organisation_id, - defaults={ - "chargebee_updated_at": timezone.now(), - "allowed_30d_api_calls": subscription_metadata.api_calls, - "allowed_seats": subscription_metadata.seats, - "organisation_id": existing_subscription.organisation_id, - "allowed_projects": subscription_metadata.projects, - "chargebee_email": subscription_metadata.chargebee_email, - }, + defaults=osic_defaults, ) return Response(status=status.HTTP_200_OK) diff --git a/api/organisations/constants.py b/api/organisations/constants.py new file mode 100644 index 000000000000..1fc68893ebce --- /dev/null +++ b/api/organisations/constants.py @@ -0,0 +1,5 @@ +API_USAGE_ALERT_THRESHOLDS = [75, 90, 100, 120] +ALERT_EMAIL_MESSAGE = ( + "Organisation %s has used %d seats which is over their plan limit of %d (plan: %s)" +) +ALERT_EMAIL_SUBJECT = "Organisation over number of seats" diff --git a/api/organisations/migrations/0051_create_org_api_usage_notification.py b/api/organisations/migrations/0051_create_org_api_usage_notification.py new file mode 100644 index 000000000000..c233a36edfe2 --- /dev/null +++ b/api/organisations/migrations/0051_create_org_api_usage_notification.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.23 on 2024-02-05 16:53 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('organisations', '0050_add_historical_subscription'), + ] + + operations = [ + migrations.AddField( + model_name='organisationsubscriptioninformationcache', + name='current_billing_term_ends_at', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='organisationsubscriptioninformationcache', + name='current_billing_term_starts_at', + field=models.DateTimeField(null=True), + ), + migrations.CreateModel( + name='OranisationAPIUsageNotification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('percent_usage', models.IntegerField(validators=[django.core.validators.MinValueValidator(75), django.core.validators.MaxValueValidator(120)])), + ('notified_at', models.DateTimeField(null=True)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True, null=True)), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_usage_notifications', to='organisations.organisation')), + ], + ), + ] diff --git a/api/organisations/models.py b/api/organisations/models.py index 14aee0c60ccc..a85aaff52b21 100644 --- a/api/organisations/models.py +++ b/api/organisations/models.py @@ -4,6 +4,7 @@ from core.models import SoftDeleteExportableModel from django.conf import settings from django.core.cache import caches +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils import timezone from django_lifecycle import ( @@ -415,6 +416,8 @@ class OrganisationSubscriptionInformationCache(models.Model): updated_at = models.DateTimeField(auto_now=True) chargebee_updated_at = models.DateTimeField(auto_now=False, null=True) influx_updated_at = models.DateTimeField(auto_now=False, null=True) + current_billing_term_starts_at = models.DateTimeField(auto_now=False, null=True) + current_billing_term_ends_at = models.DateTimeField(auto_now=False, null=True) api_calls_24h = models.IntegerField(default=0) api_calls_7d = models.IntegerField(default=0) @@ -425,3 +428,17 @@ class OrganisationSubscriptionInformationCache(models.Model): allowed_projects = models.IntegerField(default=1, blank=True, null=True) chargebee_email = models.EmailField(blank=True, max_length=254, null=True) + + +class OranisationAPIUsageNotification(models.Model): + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="api_usage_notifications" + ) + percent_usage = models.IntegerField( + null=False, + validators=[MinValueValidator(75), MaxValueValidator(120)], + ) + notified_at = models.DateTimeField(null=True) + + created_at = models.DateTimeField(null=True, auto_now_add=True) + updated_at = models.DateTimeField(null=True, auto_now=True) diff --git a/api/organisations/tasks.py b/api/organisations/tasks.py index 32f58d1ff0b3..b94609a4b2e7 100644 --- a/api/organisations/tasks.py +++ b/api/organisations/tasks.py @@ -1,9 +1,20 @@ +import logging from datetime import timedelta +from app_analytics.influxdb_wrapper import get_current_api_usage +from dateutil.relativedelta import relativedelta +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string from django.utils import timezone from organisations import subscription_info_cache -from organisations.models import Organisation, Subscription +from organisations.models import ( + OranisationAPIUsageNotification, + Organisation, + OrganisationRole, + Subscription, +) from organisations.subscriptions.subscription_service import ( get_subscription_metadata, ) @@ -13,12 +24,14 @@ ) from users.models import FFAdminUser +from .constants import ( + ALERT_EMAIL_MESSAGE, + ALERT_EMAIL_SUBJECT, + API_USAGE_ALERT_THRESHOLDS, +) from .subscriptions.constants import SubscriptionCacheEntity -ALERT_EMAIL_MESSAGE = ( - "Organisation %s has used %d seats which is over their plan limit of %d (plan: %s)" -) -ALERT_EMAIL_SUBJECT = "Organisation over number of seats" +logger = logging.getLogger(__name__) @register_task_handler() @@ -73,3 +86,97 @@ def finish_subscription_cancellation(): ): subscription.organisation.cancel_users() subscription.save_as_free_subscription() + + +def send_admin_api_usage_notification( + organisation: Organisation, matched_threshold: int +) -> None: + """ + Send notification to admins that the API has breached a threshold. + """ + + recipient_list = FFAdminUser.objects.filter( + userorganisation__organisation=organisation, + ) + + if matched_threshold < 100: + message = "organisations/api_usage_notification.txt" + html_message = "organisations/api_usage_notification.html" + + # Since threshold < 100 only include admins. + recipient_list = recipient_list.filter( + userorganisation__role=OrganisationRole.ADMIN, + ) + else: + message = "organisations/api_usage_notification_limit.txt" + html_message = "organisations/api_usage_notification_limit.html" + + context = { + "organisation": organisation, + "matched_threshold": matched_threshold, + } + + send_mail( + subject=f"Flagsmith API use has reached {matched_threshold}%", + message=render_to_string(message, context), + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=list(recipient_list.values_list("email", flat=True)), + html_message=render_to_string(html_message, context), + fail_silently=True, + ) + + OranisationAPIUsageNotification.objects.create( + organisation=organisation, + percent_usage=matched_threshold, + notified_at=timezone.now(), + ) + + +def _handle_api_usage_notifications(organisation: Organisation): + 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 + + 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) + + matched_threshold = None + for threshold in API_USAGE_ALERT_THRESHOLDS: + if threshold > api_usage_percent: + break + + matched_threshold = threshold + + if OranisationAPIUsageNotification.objects.filter( + notified_at__gt=period_starts_at, + percent_usage=matched_threshold, + ).exists(): + # Already sent the max notification level so don't resend. + return + + send_admin_api_usage_notification(organisation, matched_threshold) + + +@register_recurring_task( + run_every=timedelta(hours=12), +) +def handle_api_usage_notifications(): + 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( + "subscription_information_cache", + ): + try: + _handle_api_usage_notifications(organisation) + except RuntimeError: + logger.error( + f"Error processing api usage for organisation {organisation.id}", + exc_info=True, + ) diff --git a/api/organisations/templates/organisations/api_usage_notification.html b/api/organisations/templates/organisations/api_usage_notification.html new file mode 100644 index 000000000000..e23d4d5f037e --- /dev/null +++ b/api/organisations/templates/organisations/api_usage_notification.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Hi there,
+ The API usage for {{ organisation.name }} has reached + {{ matched_threshold }}% within the current subscription period. + Please consider upgrading your organisations account limits. +
Thank you!
The Flagsmith Team
diff --git a/api/organisations/templates/organisations/api_usage_notification.txt b/api/organisations/templates/organisations/api_usage_notification.txt new file mode 100644 index 000000000000..b13042132ea6 --- /dev/null +++ b/api/organisations/templates/organisations/api_usage_notification.txt @@ -0,0 +1,7 @@ +Hi there, + +The API usage for {{ organisation.name }} has reached {{ matched_threshold }}% within the current subscription period. Please consider upgrading your organisations account limits. + +Thank you! + +The Flagsmith Team diff --git a/api/organisations/templates/organisations/api_usage_notification_limit.html b/api/organisations/templates/organisations/api_usage_notification_limit.html new file mode 100644 index 000000000000..3e9c1ae577cf --- /dev/null +++ b/api/organisations/templates/organisations/api_usage_notification_limit.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Hi there,
+ The API usage for {{ organisation.name }} has breached + {{ matched_threshold }}% within the current subscription period. + Please upgrade your organisations account to ensure continued service. +
Thank you!
The Flagsmith Team
diff --git a/api/organisations/templates/organisations/api_usage_notification_limit.txt b/api/organisations/templates/organisations/api_usage_notification_limit.txt new file mode 100644 index 000000000000..4e39e7adc54a --- /dev/null +++ b/api/organisations/templates/organisations/api_usage_notification_limit.txt @@ -0,0 +1,7 @@ +Hi there, + +The API usage for {{ organisation.name }} has breached {{ matched_threshold }}% within the current subscription period. Please upgrade your organisations account to ensure continued service. + +Thank you! + +The Flagsmith Team diff --git a/api/tests/unit/organisations/test_unit_organisations_tasks.py b/api/tests/unit/organisations/test_unit_organisations_tasks.py index 8f103ac67ecd..d5c51a47a645 100644 --- a/api/tests/unit/organisations/test_unit_organisations_tasks.py +++ b/api/tests/unit/organisations/test_unit_organisations_tasks.py @@ -2,11 +2,13 @@ from datetime import timedelta import pytest +from django.core.mail.message import EmailMultiAlternatives from django.utils import timezone from pytest_mock import MockerFixture from organisations.chargebee.metadata import ChargebeeObjMetadata from organisations.models import ( + OranisationAPIUsageNotification, Organisation, OrganisationRole, OrganisationSubscriptionInformationCache, @@ -22,6 +24,7 @@ ALERT_EMAIL_MESSAGE, ALERT_EMAIL_SUBJECT, finish_subscription_cancellation, + handle_api_usage_notifications, send_org_over_limit_alert, send_org_subscription_cancelled_alert, ) @@ -222,3 +225,177 @@ def test_send_org_subscription_cancelled_alert(db: None, mocker: MockerFixture) recipient_list=[], fail_silently=True, ) + + +def test_handle_api_usage_notifications_below_100( + mocker: MockerFixture, + organisation: Organisation, + mailoutbox: list[EmailMultiAlternatives], +) -> None: + # Given + now = timezone.now() + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + allowed_seats=10, + allowed_projects=3, + allowed_30d_api_calls=100, + chargebee_email="test@example.com", + current_billing_term_starts_at=now - timedelta(days=45), + current_billing_term_ends_at=now + timedelta(days=320), + ) + mock_api_usage = mocker.patch( + "organisations.tasks.get_current_api_usage", + ) + mock_api_usage.return_value = 91 + assert not OranisationAPIUsageNotification.objects.filter( + organisation=organisation, + ).exists() + + # When + handle_api_usage_notifications() + + # Then + mock_api_usage.assert_called_once_with(organisation.id, "14d") + + assert len(mailoutbox) == 1 + email = mailoutbox[0] + assert email.subject == "Flagsmith API use has reached 90%" + assert email.body == ( + "Hi there,\n\nThe API usage for Test Org has reached " + "90% within the current subscription period. Please " + "consider upgrading your organisations account limits.\n\n" + "Thank you!\n\nThe 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] == ( + "\n\n \n\n " + "\n\n \n\n " + "\n\n \n\n\n \n\n " + "\n\n \n\n " + " \n\n \n\n " + "\n\n " + "\n\n
Hi there,
\n " + "The API usage for Test Org has reached\n " + "90% within the current subscription period.\n " + "Please consider upgrading your organisations account limits.\n" + "
Thank you!
The Flagsmith Team
\n" + ) + + assert email.from_email == "noreply@flagsmith.com" + # Only admin because threshold is under 100. + assert email.to == ["admin@example.com"] + + assert ( + OranisationAPIUsageNotification.objects.filter( + organisation=organisation, + ).count() + == 1 + ) + api_usage_notification = OranisationAPIUsageNotification.objects.filter( + organisation=organisation, + ).first() + + assert api_usage_notification.percent_usage == 90 + + # Now re-run the usage to make sure the notification isn't resent. + handle_api_usage_notifications() + + assert ( + OranisationAPIUsageNotification.objects.filter( + organisation=organisation, + ).count() + == 1 + ) + assert OranisationAPIUsageNotification.objects.first() == api_usage_notification + + +def test_handle_api_usage_notifications_above_100( + mocker: MockerFixture, + organisation: Organisation, + mailoutbox: list[EmailMultiAlternatives], +) -> None: + # Given + now = timezone.now() + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + allowed_seats=10, + allowed_projects=3, + allowed_30d_api_calls=100, + chargebee_email="test@example.com", + current_billing_term_starts_at=now - timedelta(days=45), + current_billing_term_ends_at=now + timedelta(days=320), + ) + mock_api_usage = mocker.patch( + "organisations.tasks.get_current_api_usage", + ) + mock_api_usage.return_value = 105 + + assert not OranisationAPIUsageNotification.objects.filter( + organisation=organisation, + ).exists() + + # When + handle_api_usage_notifications() + + # Then + mock_api_usage.assert_called_once_with(organisation.id, "14d") + + 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 " + "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] == ( + "\n\n \n\n \n\n \n\n \n\n " + " \n\n\n " + " \n\n \n\n \n\n \n\n \n\n" + " \n\n " + "\n\n
Hi " + "there,
\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
" + "Thank you!
The Flagsmith Team
\n" + ) + + assert email.from_email == "noreply@flagsmith.com" + # Extra staff included because threshold is over 100. + assert email.to == ["admin@example.com", "staff@example.com"] + + assert ( + OranisationAPIUsageNotification.objects.filter( + organisation=organisation, + ).count() + == 1 + ) + api_usage_notification = OranisationAPIUsageNotification.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 ( + OranisationAPIUsageNotification.objects.filter( + organisation=organisation, + ).count() + == 1 + ) + assert OranisationAPIUsageNotification.objects.first() == api_usage_notification diff --git a/api/tests/unit/organisations/test_unit_organisations_views.py b/api/tests/unit/organisations/test_unit_organisations_views.py index 93888702d39f..43390940bd6a 100644 --- a/api/tests/unit/organisations/test_unit_organisations_views.py +++ b/api/tests/unit/organisations/test_unit_organisations_views.py @@ -568,6 +568,8 @@ def test_chargebee_webhook( "subscription": { "status": "active", "id": subscription.subscription_id, + "current_term_start": 1699630389, + "current_term_end": 1702222389, }, "customer": {"email": staff_user.email}, } @@ -586,6 +588,12 @@ def test_chargebee_webhook( subscription_cache = OrganisationSubscriptionInformationCache.objects.get( organisation=subscription.organisation ) + assert subscription_cache.current_billing_term_ends_at == datetime( + 2023, 12, 10, 15, 33, 9, tzinfo=timezone.utc + ) + assert subscription_cache.current_billing_term_starts_at == datetime( + 2023, 11, 10, 15, 33, 9, tzinfo=timezone.utc + ) assert subscription_cache.allowed_projects is None assert subscription_cache.allowed_30d_api_calls == api_calls assert subscription_cache.allowed_seats == seats diff --git a/frontend/web/components/OrganisationUsage.tsx b/frontend/web/components/OrganisationUsage.tsx index 0f6abd9b8bdc..731c46e2df44 100644 --- a/frontend/web/components/OrganisationUsage.tsx +++ b/frontend/web/components/OrganisationUsage.tsx @@ -69,7 +69,9 @@ const OrganisationUsage: FC = ({ organisationId }) => {
- Please be aware that usage data can be delayed by up to 3 hours. + Please be aware that usage data can be delayed by up to 3 hours and that + these numbers show the API usage for the last 30 days, + not your current billing period which may differ.