Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 4 additions & 6 deletions api/app_analytics/analytics_db_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from environments.models import Environment
from features.models import Feature
from organisations.models import Organisation, OrganisationSubscriptionInformationCache
from organisations.services import get_current_billing_period_start_date

logger = structlog.get_logger("app_analytics")

Expand Down Expand Up @@ -341,8 +342,7 @@ def _get_start_date_and_stop_date_for_subscribed_organisation(
else:
raise NotFound("No billing periods found for this organisation.")

month_delta = relativedelta(now, starts_at).months
date_start = relativedelta(months=month_delta) + starts_at
date_start = get_current_billing_period_start_date(starts_at, now)
return date_start, now

case constants.PREVIOUS_BILLING_PERIOD:
Expand All @@ -351,10 +351,8 @@ def _get_start_date_and_stop_date_for_subscribed_organisation(
else:
raise NotFound("No billing periods found for this organisation.")

month_delta = relativedelta(now, starts_at).months - 1
month_delta += relativedelta(now, starts_at).years * 12
date_start = relativedelta(months=month_delta) + starts_at
date_stop = relativedelta(months=month_delta + 1) + starts_at
date_stop = get_current_billing_period_start_date(starts_at, now)
date_start = date_stop - relativedelta(months=1)
return date_start, date_stop

case constants.NINETY_DAY_PERIOD:
Expand Down
21 changes: 21 additions & 0 deletions api/organisations/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from datetime import datetime

from dateutil.relativedelta import relativedelta


def get_current_billing_period_start_date(
billing_term_starts_at: datetime,
now: datetime,
) -> datetime:
"""
Return the start of the monthly period an organisation is currently in.

A billing term can be longer than a month, an annual plan being the common
case, but API usage is allowed per month. The current period therefore
starts at the most recent monthly anniversary of the term start, which for
a term that began more than a year ago means counting the years as well as
the months.
"""
elapsed = relativedelta(now, billing_term_starts_at)
months_elapsed = elapsed.years * 12 + elapsed.months
return billing_term_starts_at + relativedelta(months=months_elapsed)
10 changes: 2 additions & 8 deletions api/organisations/task_helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from datetime import timedelta

import structlog
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
Expand All @@ -16,6 +15,7 @@
OrganisationAPIUsageNotification,
OrganisationRole,
)
from organisations.services import get_current_billing_period_start_date
from organisations.subscriptions.constants import MAX_API_CALLS_IN_FREE_PLAN
from users.models import FFAdminUser

Expand Down Expand Up @@ -123,9 +123,7 @@ def handle_api_usage_notification_for_organisation(organisation: Organisation) -
)
return

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

allowed_api_calls = subscription_cache.allowed_30d_api_calls

Expand Down Expand Up @@ -182,7 +180,3 @@ def handle_api_usage_notification_for_organisation(organisation: Organisation) -
)

_send_api_usage_notification(organisation, matched_threshold)


def _get_total_months(rd: relativedelta) -> int:
return rd.months + rd.years * 12
5 changes: 2 additions & 3 deletions api/organisations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import logging
from datetime import timedelta

from dateutil.relativedelta import relativedelta
from django.utils import timezone
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import status, viewsets
Expand Down Expand Up @@ -48,6 +47,7 @@
SubscriptionDetailsSerializer,
UpdateSubscriptionSerializer,
)
from organisations.services import get_current_billing_period_start_date
from permissions.permissions_calculator import get_organisation_permission_data
from permissions.serializers import (
PermissionModelSerializer,
Expand Down Expand Up @@ -393,8 +393,7 @@ def get_queryset(self): # type: ignore[no-untyped-def]
# by defaulting to something as a reasonable default.
billing_starts_at = billing_starts_at or now - timedelta(days=30)

month_delta = relativedelta(now, billing_starts_at).months
period_starts_at = relativedelta(months=month_delta) + billing_starts_at
period_starts_at = get_current_billing_period_start_date(billing_starts_at, now)

queryset = OrganisationAPIUsageNotification.objects.filter(
organisation_id=organisation.id,
Expand Down
33 changes: 33 additions & 0 deletions api/tests/unit/app_analytics/test_analytics_db_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,39 @@ def test_get_usage_data__current_billing_period__passes_correct_date_range(
)


@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00")
def test_get_usage_data__current_billing_period_annual_term__passes_correct_date_range(
mocker: MockerFixture,
settings: SettingsWrapper,
organisation: Organisation,
cache: OrganisationSubscriptionInformationCache,
) -> None:
# Given
# A term that started more than twelve months ago, as an annual plan does.
settings.USE_POSTGRES_FOR_ANALYTICS = True
cache.current_billing_term_starts_at = datetime(
2021, 12, 30, 9, 9, 47, 325132, tzinfo=UTC
)
cache.save()
mocked_get_usage_data_from_local_db = mocker.patch(
"app_analytics.analytics_db_service.get_usage_data_from_local_db", autospec=True
)

# When
get_usage_data(organisation, period=CURRENT_BILLING_PERIOD)

# Then
# The current month of the term, not the same month a year earlier.
mocked_get_usage_data_from_local_db.assert_called_once_with(
organisation=organisation,
environment_id=None,
project_id=None,
date_start=datetime(2022, 12, 30, 9, 9, 47, 325132, tzinfo=UTC),
date_stop=datetime(2023, 1, 19, 9, 9, 47, 325132, tzinfo=UTC),
labels_filter=None,
)


@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00")
def test_get_usage_data__previous_billing_period__passes_correct_date_range(
mocker: MockerFixture,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from datetime import UTC, datetime

import pytest

from organisations.services import get_current_billing_period_start_date


@pytest.mark.parametrize(
"billing_term_starts_at, now, expected",
[
pytest.param(
datetime(2026, 1, 10, 9, 0, tzinfo=UTC),
datetime(2026, 1, 20, 9, 0, tzinfo=UTC),
datetime(2026, 1, 10, 9, 0, tzinfo=UTC),
id="first_month_of_the_term",
),
pytest.param(
datetime(2026, 1, 10, 9, 0, tzinfo=UTC),
datetime(2026, 5, 3, 9, 0, tzinfo=UTC),
datetime(2026, 4, 10, 9, 0, tzinfo=UTC),
id="part_way_through_a_monthly_term",
),
pytest.param(
# An annual term, well over a year old: the months-only delta used
# to drop the years and land a period a year early.
datetime(2024, 9, 15, 9, 0, tzinfo=UTC),
datetime(2026, 7, 2, 9, 0, tzinfo=UTC),
datetime(2026, 6, 15, 9, 0, tzinfo=UTC),
id="term_older_than_a_year",
),
pytest.param(
datetime(2024, 9, 15, 9, 0, tzinfo=UTC),
datetime(2026, 9, 15, 9, 0, tzinfo=UTC),
datetime(2026, 9, 15, 9, 0, tzinfo=UTC),
id="exactly_on_an_anniversary",
),
],
)
def test_get_current_billing_period_start_date__returns_latest_monthly_anniversary(
billing_term_starts_at: datetime,
now: datetime,
expected: datetime,
) -> None:
# When
result = get_current_billing_period_start_date(billing_term_starts_at, now)

# Then
assert result == expected
assert result <= now
46 changes: 46 additions & 0 deletions api/tests/unit/organisations/test_unit_organisations_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2055,6 +2055,52 @@ def test_get_api_usage_notifications__with_cache__returns_highest_notification(
assert response.data["results"][0]["percent_usage"] == 100


@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00")
def test_get_api_usage_notifications__annual_term__returns_current_period_notification(
staff_client: APIClient,
organisation: Organisation,
) -> None:
# Given
# A term that started more than twelve months ago, as an annual plan does.
url = reverse(
"api-v1:organisations:organisation-api-usage-notification",
args=[organisation.id],
)

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=400),
current_billing_term_ends_at=now + timedelta(days=330),
)

# Notified in a month of the term that has already passed, so it should not
# be returned. Counting only the months of the delta would have included it.
OrganisationAPIUsageNotification.objects.create(
organisation=organisation,
percent_usage=90,
notified_at=now - timedelta(days=60),
)
OrganisationAPIUsageNotification.objects.create(
organisation=organisation,
percent_usage=75,
notified_at=now,
)

# When
response = staff_client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK

assert len(response.data["results"]) == 1
assert response.data["results"][0]["percent_usage"] == 75


@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00")
def test_get_api_usage_notifications__stale_notification__returns_empty(
staff_client: APIClient,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
### `api_usage.notification.evaluated`

Logged at `info` from:
- `api/organisations/task_helpers.py:155`
- `api/organisations/task_helpers.py:153`

Attributes:
- `allowed_api_calls`
Expand All @@ -24,7 +24,7 @@ Attributes:
### `api_usage.notification.sent`

Logged at `info` from:
- `api/organisations/task_helpers.py:178`
- `api/organisations/task_helpers.py:176`

Attributes:
- `matched_threshold`
Expand All @@ -33,9 +33,9 @@ Attributes:
### `app_analytics.no_analytics_database_configured`

Logged at `warning` from:
- `api/app_analytics/analytics_db_service.py:74`
- `api/app_analytics/analytics_db_service.py:187`
- `api/app_analytics/analytics_db_service.py:278`
- `api/app_analytics/analytics_db_service.py:75`
- `api/app_analytics/analytics_db_service.py:188`
- `api/app_analytics/analytics_db_service.py:279`

Attributes:
- `details`
Expand Down
Loading