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

feat: Create api usage function #3340

Merged
merged 27 commits into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7754ce3
Create api usage function with WIP notes
zachaysan Jan 29, 2024
3d730dc
Add current billing term fields to subcription cache model
zachaysan Feb 2, 2024
89a6222
Add billing term to chargebee webhook test
zachaysan Feb 2, 2024
96b6b8b
Expand serializer
zachaysan Feb 2, 2024
49cd5c3
Add current billing term values when present
zachaysan Feb 2, 2024
20257df
Fix conflicts and merge branch 'main' into feat/add_api_usage_nudge
zachaysan Feb 2, 2024
3ab44fd
Replace migration with subsequent one
zachaysan Feb 5, 2024
36fdd9b
Add notification related model
zachaysan Feb 5, 2024
90fcd85
Create organisations constants
zachaysan Feb 5, 2024
630285a
Add api usage notification
zachaysan Feb 5, 2024
d28b6f0
Add test for api usage notifications
zachaysan Feb 5, 2024
2090fb0
Implement api usage notification tasks
zachaysan Feb 5, 2024
70668e0
Remove WIP comments
zachaysan Feb 5, 2024
7973710
Merge branch 'main' into feat/add_api_usage_nudge
zachaysan Feb 5, 2024
3ce3510
Trigger build
zachaysan Feb 6, 2024
afb22ef
Update docstring
zachaysan Feb 12, 2024
8de4c4b
Add a note to the organisation usage page
zachaysan Feb 12, 2024
720e1c7
Update email messages
zachaysan Feb 12, 2024
6e62855
Add email threshold for notification messages
zachaysan Feb 12, 2024
dbce803
Update test to include HTML response
zachaysan Feb 12, 2024
4a96d35
Merge branch 'main' into feat/add_api_usage_nudge
zachaysan Feb 12, 2024
c30fc3b
Add test for over 100 threshold
zachaysan Feb 14, 2024
5907157
Include non-admins for higher threshold notifications
zachaysan Feb 14, 2024
28f6a15
Merge branch 'main' into feat/add_api_usage_nudge
zachaysan Feb 14, 2024
f0821b8
Switch around how the filter works
zachaysan Feb 15, 2024
1dce1f6
Switch to mailoutbox fixture
zachaysan Feb 15, 2024
13126e4
Merge branch 'main' into feat/add_api_usage_nudge
zachaysan Feb 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion api/app_analytics/influxdb_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]]
Expand Down
1 change: 1 addition & 0 deletions api/organisations/chargebee/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 29 additions & 9 deletions api/organisations/chargebee/webhook_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
5 changes: 5 additions & 0 deletions api/organisations/constants.py
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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')),
],
),
]
17 changes: 17 additions & 0 deletions api/organisations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand All @@ -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)
113 changes: 108 additions & 5 deletions api/organisations/tasks.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand All @@ -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()
Expand Down Expand Up @@ -73,3 +86,93 @@ 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 = list(
FFAdminUser.objects.filter(
userorganisation__organisation=organisation,
userorganisation__role=OrganisationRole.ADMIN,
).values_list("email", flat=True)
)

if matched_threshold < 100:
message = "organisations/api_usage_notification.txt"
html_message = "organisations/api_usage_notification.html"
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=recipient_list,
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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<table>

<tr>

<td>Hi there,</td>

</tr>

<tr>

<td>
The API usage for {{ organisation.name }} has reached
{{ matched_threshold }}% within the current subscription period.
Please consider upgrading your organisations account limits.
</td>


</tr>

<tr>

<td>Thank you!</td>

</tr>

<tr>

<td>The Flagsmith Team</td>

</tr>

</table>
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading