From e28109c0d501a5ef4b3e676922bdd42f6ff44865 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 24 Mar 2026 00:53:57 -0300 Subject: [PATCH 1/7] Update subscription cache from database-backed usage --- api/app_analytics/analytics_db_service.py | 38 ++++++++- api/organisations/subscription_info_cache.py | 22 +++-- api/organisations/subscriptions/constants.py | 3 +- api/organisations/tasks.py | 8 +- .../test_analytics_db_service.py | 81 +++++++++++++++++++ ...t_organisations_subscription_info_cache.py | 77 +++++++++++++++++- 6 files changed, 215 insertions(+), 14 deletions(-) diff --git a/api/app_analytics/analytics_db_service.py b/api/app_analytics/analytics_db_service.py index 106eaed9f645..cb3c48198d20 100644 --- a/api/app_analytics/analytics_db_service.py +++ b/api/app_analytics/analytics_db_service.py @@ -1,7 +1,8 @@ +from collections import defaultdict from datetime import datetime, timedelta import structlog -from common.core.utils import using_database_replica +from common.core.utils import is_saas, using_database_replica from dateutil.relativedelta import relativedelta from django.conf import settings from django.db.models import Q, Sum @@ -122,6 +123,41 @@ def get_usage_data_from_local_db( return map_annotated_api_usage_buckets_to_usage_data(qs) +def get_top_organisations_from_local_db( + date_start: datetime, +) -> dict[int, int]: + """ + Return a mapping of organisation ID to total API call count from the + Postgres analytics database, for all organisations with usage since + ``date_start``. Self-hosted deployments only. + """ + if is_saas(): + raise RuntimeError("Must not run in SaaS mode") + + environment_id_to_organisation_id: dict[int, int] = dict( + using_database_replica(Environment.objects).values_list( + "id", "project__organisation_id" + ) + ) + + usage_per_environment = ( + APIUsageBucket.objects.filter( + created_at__gte=date_start, + bucket_size=constants.ANALYTICS_READ_BUCKET_SIZE, + ) + .values("environment_id") + .annotate(total=Sum("total_count")) + ) + + calls_per_organisation: defaultdict[int, int] = defaultdict(int) + for row in usage_per_environment: + organisation_id = environment_id_to_organisation_id.get(row["environment_id"]) + if organisation_id is not None: + calls_per_organisation[organisation_id] += row["total"] + + return dict(calls_per_organisation) + + def get_total_events_count( organisation: Organisation, date_start: datetime | None = None, diff --git a/api/organisations/subscription_info_cache.py b/api/organisations/subscription_info_cache.py index 82e091bffb3c..ac88a5af4dfe 100644 --- a/api/organisations/subscription_info_cache.py +++ b/api/organisations/subscription_info_cache.py @@ -4,6 +4,7 @@ from django.conf import settings from django.utils import timezone +from app_analytics.analytics_db_service import get_top_organisations_from_local_db from app_analytics.influxdb_wrapper import get_top_organisations from .chargebee import get_subscription_metadata_from_id # type: ignore[attr-defined] @@ -32,8 +33,11 @@ def update_caches(update_cache_entities: typing.Tuple[SubscriptionCacheEntity, . for org in organisations } - if SubscriptionCacheEntity.INFLUX in update_cache_entities: - _update_caches_with_influx_data(organisation_info_cache_dict) + if ( + SubscriptionCacheEntity.API_USAGE in update_cache_entities + or SubscriptionCacheEntity.INFLUX in update_cache_entities + ): + _update_caches_with_api_usage_data(organisation_info_cache_dict) if SubscriptionCacheEntity.CHARGEBEE in update_cache_entities: _update_caches_with_chargebee_data(organisations, organisation_info_cache_dict) @@ -64,14 +68,17 @@ def update_caches(update_cache_entities: typing.Tuple[SubscriptionCacheEntity, . ) -def _update_caches_with_influx_data( +def _update_caches_with_api_usage_data( organisation_info_cache_dict: OrganisationSubscriptionInformationCacheDict, ) -> None: """ Mutates the provided organisation_info_cache_dict in place to add information about the organisation's - influx usage. + API usage, sourced from either Postgres or InfluxDB. """ - if not settings.INFLUXDB_TOKEN: + use_postgres = settings.USE_POSTGRES_FOR_ANALYTICS + use_influx = bool(settings.INFLUXDB_TOKEN) + + if not use_postgres and not use_influx: return for _date_start, limit in (("-30d", ""), ("-7d", ""), ("-24h", "100")): @@ -85,7 +92,10 @@ def _update_caches_with_influx_data( else: assert False, "Expecting either days (d) or hours (h)" # pragma: no cover - org_calls = get_top_organisations(date_start, limit) + if use_postgres: + org_calls = get_top_organisations_from_local_db(date_start) + else: + org_calls = get_top_organisations(date_start, limit) covered_orgs = set() diff --git a/api/organisations/subscriptions/constants.py b/api/organisations/subscriptions/constants.py index effd87d182f6..27220bb36a76 100644 --- a/api/organisations/subscriptions/constants.py +++ b/api/organisations/subscriptions/constants.py @@ -51,7 +51,8 @@ class SubscriptionCacheEntity(Enum): - INFLUX = "INFLUX" + INFLUX = "INFLUX" # Deprecated alias — use API_USAGE. + API_USAGE = "API_USAGE" CHARGEBEE = "CHARGEBEE" diff --git a/api/organisations/tasks.py b/api/organisations/tasks.py index 4c689857e4d9..5bc8830734af 100644 --- a/api/organisations/tasks.py +++ b/api/organisations/tasks.py @@ -85,23 +85,23 @@ def send_org_subscription_cancelled_alert( @register_recurring_task( run_every=timedelta(hours=6), ) -def update_organisation_subscription_information_influx_cache_recurring(): # type: ignore[no-untyped-def] +def update_organisation_subscription_information_cache_recurring(): # type: ignore[no-untyped-def] """ We're redefining the task function here to register a recurring task since the decorators don't stack correctly. (TODO) """ - update_organisation_subscription_information_influx_cache() # pragma: no cover + update_organisation_subscription_information_cache() # pragma: no cover @register_task_handler() def update_organisation_subscription_information_influx_cache(): # type: ignore[no-untyped-def] - subscription_info_cache.update_caches((SubscriptionCacheEntity.INFLUX,)) + subscription_info_cache.update_caches((SubscriptionCacheEntity.API_USAGE,)) @register_task_handler(timeout=timedelta(minutes=5)) def update_organisation_subscription_information_cache() -> None: subscription_info_cache.update_caches( - (SubscriptionCacheEntity.CHARGEBEE, SubscriptionCacheEntity.INFLUX) + (SubscriptionCacheEntity.CHARGEBEE, SubscriptionCacheEntity.API_USAGE) ) diff --git a/api/tests/unit/app_analytics/test_analytics_db_service.py b/api/tests/unit/app_analytics/test_analytics_db_service.py index 84191490a917..bd527da826e3 100644 --- a/api/tests/unit/app_analytics/test_analytics_db_service.py +++ b/api/tests/unit/app_analytics/test_analytics_db_service.py @@ -9,6 +9,7 @@ from app_analytics.analytics_db_service import ( get_feature_evaluation_data, get_feature_evaluation_data_from_local_db, + get_top_organisations_from_local_db, get_total_events_count, get_usage_data, get_usage_data_from_local_db, @@ -731,3 +732,83 @@ def test_get_usage_data__previous_billing_period__passes_correct_date_range( date_stop=datetime(2022, 12, 30, 9, 9, 47, 325132, tzinfo=UTC), labels_filter=None, ) + + +@pytest.mark.use_analytics_db +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_get_top_organisations_from_local_db__with_data__returns_correct_mapping( + organisation: Organisation, + environment: Environment, + settings: SettingsWrapper, +) -> None: + # Given + now = timezone.now() + read_bucket_size = 15 + settings.ANALYTICS_BUCKET_SIZE = read_bucket_size + date_start = now - timedelta(days=30) + + for i in range(3): + APIUsageBucket.objects.create( + environment_id=environment.id, + resource=Resource.FLAGS, + total_count=100, + bucket_size=read_bucket_size, + created_at=now - timedelta(days=i), + ) + + # When + result = get_top_organisations_from_local_db(date_start) + + # Then + assert result == {organisation.id: 300} + + +@pytest.mark.use_analytics_db +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_get_top_organisations_from_local_db__buckets_before_date_start__excluded( + organisation: Organisation, + environment: Environment, + settings: SettingsWrapper, +) -> None: + # Given + now = timezone.now() + read_bucket_size = 15 + settings.ANALYTICS_BUCKET_SIZE = read_bucket_size + date_start = now - timedelta(days=7) + + # Bucket within range + APIUsageBucket.objects.create( + environment_id=environment.id, + resource=Resource.FLAGS, + total_count=50, + bucket_size=read_bucket_size, + created_at=now - timedelta(days=3), + ) + # Bucket outside range (before date_start) + APIUsageBucket.objects.create( + environment_id=environment.id, + resource=Resource.FLAGS, + total_count=200, + bucket_size=read_bucket_size, + created_at=now - timedelta(days=10), + ) + + # When + result = get_top_organisations_from_local_db(date_start) + + # Then + assert result == {organisation.id: 50} + + +def test_get_top_organisations_from_local_db__saas_mode__raises_runtime_error( + mocker: MockerFixture, +) -> None: + # Given + mocker.patch( + "app_analytics.analytics_db_service.is_saas", + return_value=True, + ) + + # When / Then + with pytest.raises(RuntimeError, match="Must not run in SaaS mode"): + get_top_organisations_from_local_db(timezone.now() - timedelta(days=30)) diff --git a/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py b/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py index 8757325b2de8..c403d40a07d1 100644 --- a/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py +++ b/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py @@ -49,7 +49,7 @@ def test_update_caches__with_usage_data__populates_cache_correctly( # type: ign # When subscription_cache_entities = ( - SubscriptionCacheEntity.INFLUX, + SubscriptionCacheEntity.API_USAGE, SubscriptionCacheEntity.CHARGEBEE, ) update_caches(subscription_cache_entities) @@ -119,7 +119,7 @@ def test_update_caches__no_usage_data__resets_cache_to_zero( # When subscription_cache_entities = ( - SubscriptionCacheEntity.INFLUX, + SubscriptionCacheEntity.API_USAGE, SubscriptionCacheEntity.CHARGEBEE, ) update_caches(subscription_cache_entities) @@ -129,3 +129,76 @@ def test_update_caches__no_usage_data__resets_cache_to_zero( assert organisation.subscription_information_cache.api_calls_24h == 0 assert organisation.subscription_information_cache.api_calls_7d == 0 assert organisation.subscription_information_cache.api_calls_30d == 0 + + +@pytest.mark.freeze_time("2023-01-19T09:09:47.325132+00:00") +def test_update_caches__postgres_analytics__populates_cache_correctly( + mocker: MockerFixture, + organisation: Organisation, + settings: SettingsWrapper, +) -> None: + # Given + settings.USE_POSTGRES_FOR_ANALYTICS = True + settings.INFLUXDB_TOKEN = None + + now = timezone.now() + day_1 = now - timedelta(hours=24) + day_7 = now - timedelta(days=7) + day_30 = now - timedelta(days=30) + organisation_usage = { + day_1: 25123, + day_7: 182957, + day_30: 804564, + } + mock_get_top_organisations_from_local_db = mocker.patch( + "organisations.subscription_info_cache.get_top_organisations_from_local_db", + side_effect=lambda t: {organisation.id: organisation_usage[t]}, + ) + mock_get_top_organisations = mocker.patch( + "organisations.subscription_info_cache.get_top_organisations" + ) + + # When + update_caches((SubscriptionCacheEntity.API_USAGE,)) + + # Then + organisation.refresh_from_db() + assert ( + organisation.subscription_information_cache.api_calls_24h + == organisation_usage[day_1] + ) + assert ( + organisation.subscription_information_cache.api_calls_7d + == organisation_usage[day_7] + ) + assert ( + organisation.subscription_information_cache.api_calls_30d + == organisation_usage[day_30] + ) + assert mock_get_top_organisations_from_local_db.call_count == 3 + assert mock_get_top_organisations.call_count == 0 + + +def test_update_caches__postgres_and_influx_configured__prefers_postgres( + mocker: MockerFixture, + organisation: Organisation, + settings: SettingsWrapper, +) -> None: + # Given + settings.USE_POSTGRES_FOR_ANALYTICS = True + settings.INFLUXDB_TOKEN = "token" + + mocked_get_top_organisations_from_local_db = mocker.patch( + "organisations.subscription_info_cache.get_top_organisations_from_local_db", + return_value={organisation.id: 42}, + ) + mock_get_top_organisations = mocker.patch( + "organisations.subscription_info_cache.get_top_organisations" + ) + + # When + update_caches((SubscriptionCacheEntity.API_USAGE,)) + + # Then + assert mocked_get_top_organisations_from_local_db.call_count == 3 + assert mock_get_top_organisations.call_count == 0 From b0df940840f4cae268e3bba7488ec54087bc4be6 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 24 Mar 2026 10:36:33 -0300 Subject: [PATCH 2/7] Improve naming --- api/app_analytics/analytics_db_service.py | 2 +- api/organisations/tasks.py | 2 +- api/sales_dashboard/urls.py | 2 +- api/sales_dashboard/views.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/app_analytics/analytics_db_service.py b/api/app_analytics/analytics_db_service.py index cb3c48198d20..38d42f8b27be 100644 --- a/api/app_analytics/analytics_db_service.py +++ b/api/app_analytics/analytics_db_service.py @@ -129,7 +129,7 @@ def get_top_organisations_from_local_db( """ Return a mapping of organisation ID to total API call count from the Postgres analytics database, for all organisations with usage since - ``date_start``. Self-hosted deployments only. + ``date_start``. Non-SaaS deployments only. """ if is_saas(): raise RuntimeError("Must not run in SaaS mode") diff --git a/api/organisations/tasks.py b/api/organisations/tasks.py index 5bc8830734af..08ccfca34628 100644 --- a/api/organisations/tasks.py +++ b/api/organisations/tasks.py @@ -94,7 +94,7 @@ def update_organisation_subscription_information_cache_recurring(): # type: ign @register_task_handler() -def update_organisation_subscription_information_influx_cache(): # type: ignore[no-untyped-def] +def update_organisation_subscription_information_api_usage_cache(): # type: ignore[no-untyped-def] subscription_info_cache.update_caches((SubscriptionCacheEntity.API_USAGE,)) diff --git a/api/sales_dashboard/urls.py b/api/sales_dashboard/urls.py index ff92cf08dbb5..c6b9ad73f9a4 100644 --- a/api/sales_dashboard/urls.py +++ b/api/sales_dashboard/urls.py @@ -49,7 +49,7 @@ ), path( "update-organisation-subscription-information-influx-cache", - views.trigger_update_organisation_subscription_information_influx_cache, + views.trigger_update_organisation_subscription_information_api_usage_cache, name="update-organisation-subscription-information-influx-cache", ), path( diff --git a/api/sales_dashboard/views.py b/api/sales_dashboard/views.py index 69cd3666c09d..9327704931dd 100644 --- a/api/sales_dashboard/views.py +++ b/api/sales_dashboard/views.py @@ -39,8 +39,8 @@ UserOrganisation, ) from organisations.tasks import ( + update_organisation_subscription_information_api_usage_cache, update_organisation_subscription_information_cache, - update_organisation_subscription_information_influx_cache, ) from projects.models import Project from users.models import FFAdminUser @@ -352,8 +352,8 @@ def download_org_data(request, organisation_id): # type: ignore[no-untyped-def] @staff_member_required() # type: ignore[misc] -def trigger_update_organisation_subscription_information_influx_cache(request): # type: ignore[no-untyped-def] - update_organisation_subscription_information_influx_cache.delay() +def trigger_update_organisation_subscription_information_api_usage_cache(request): # type: ignore[no-untyped-def] + update_organisation_subscription_information_api_usage_cache.delay() return HttpResponseRedirect(reverse("sales_dashboard:index")) From 1a5694db8940ac600626fddbb0caa07b33db832e Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 24 Mar 2026 10:44:08 -0300 Subject: [PATCH 3/7] Improve documentation --- api/organisations/subscription_info_cache.py | 3 +++ api/organisations/subscriptions/constants.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/api/organisations/subscription_info_cache.py b/api/organisations/subscription_info_cache.py index ac88a5af4dfe..cd14f47a9629 100644 --- a/api/organisations/subscription_info_cache.py +++ b/api/organisations/subscription_info_cache.py @@ -35,6 +35,9 @@ def update_caches(update_cache_entities: typing.Tuple[SubscriptionCacheEntity, . if ( SubscriptionCacheEntity.API_USAGE in update_cache_entities + # NOTE: SubscriptionCacheEntity.INFLUX is superseded, but must live + # briefly for the sake of task processor continuity during the release. + # TODO: https://github.com/Flagsmith/flagsmith/pull/7024 or SubscriptionCacheEntity.INFLUX in update_cache_entities ): _update_caches_with_api_usage_data(organisation_info_cache_dict) diff --git a/api/organisations/subscriptions/constants.py b/api/organisations/subscriptions/constants.py index 27220bb36a76..2bb858a3fd0f 100644 --- a/api/organisations/subscriptions/constants.py +++ b/api/organisations/subscriptions/constants.py @@ -51,7 +51,7 @@ class SubscriptionCacheEntity(Enum): - INFLUX = "INFLUX" # Deprecated alias — use API_USAGE. + INFLUX = "INFLUX" # Deprecated — use API_USAGE. API_USAGE = "API_USAGE" CHARGEBEE = "CHARGEBEE" From 970afd90bb6b242dae5c451aa1806634a1592a4d Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 24 Mar 2026 11:16:41 -0300 Subject: [PATCH 4/7] Make linters happy --- api/organisations/tasks.py | 18 ++----- ...t_organisations_subscription_info_cache.py | 37 +++++++++++++ .../test_unit_organisations_tasks.py | 53 +++++++++++++++++++ 3 files changed, 95 insertions(+), 13 deletions(-) diff --git a/api/organisations/tasks.py b/api/organisations/tasks.py index 08ccfca34628..774a13d71ba6 100644 --- a/api/organisations/tasks.py +++ b/api/organisations/tasks.py @@ -82,19 +82,13 @@ def send_org_subscription_cancelled_alert( ) -@register_recurring_task( - run_every=timedelta(hours=6), -) -def update_organisation_subscription_information_cache_recurring(): # type: ignore[no-untyped-def] - """ - We're redefining the task function here to register a recurring task - since the decorators don't stack correctly. (TODO) - """ - update_organisation_subscription_information_cache() # pragma: no cover +@register_recurring_task(run_every=timedelta(hours=6)) +def update_organisation_subscription_information_cache_recurring() -> None: + update_organisation_subscription_information_cache() @register_task_handler() -def update_organisation_subscription_information_api_usage_cache(): # type: ignore[no-untyped-def] +def update_organisation_subscription_information_api_usage_cache() -> None: subscription_info_cache.update_caches((SubscriptionCacheEntity.API_USAGE,)) @@ -105,9 +99,7 @@ def update_organisation_subscription_information_cache() -> None: ) -@register_recurring_task( - run_every=timedelta(hours=12), -) +@register_recurring_task(run_every=timedelta(hours=12)) def finish_subscription_cancellation() -> None: now = timezone.now() previously = now + timedelta(hours=-24) diff --git a/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py b/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py index c403d40a07d1..338f5045d22c 100644 --- a/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py +++ b/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py @@ -202,3 +202,40 @@ def test_update_caches__postgres_and_influx_configured__prefers_postgres( # Then assert mocked_get_top_organisations_from_local_db.call_count == 3 assert mock_get_top_organisations.call_count == 0 + + +def test_update_caches__no_analytics_source_configured__skips_api_usage_update( + mocker: MockerFixture, + organisation: Organisation, + settings: SettingsWrapper, +) -> None: + # Given + settings.USE_POSTGRES_FOR_ANALYTICS = False + settings.INFLUXDB_TOKEN = "" + + OrganisationSubscriptionInformationCache.objects.create( + organisation=organisation, + api_calls_24h=100, + api_calls_7d=700, + api_calls_30d=3000, + ) + + mock_get_top_organisations_from_local_db = mocker.patch( + "organisations.subscription_info_cache.get_top_organisations_from_local_db", + ) + mock_get_top_organisations = mocker.patch( + "organisations.subscription_info_cache.get_top_organisations", + ) + + # When + update_caches((SubscriptionCacheEntity.API_USAGE,)) + + # Then — neither analytics source was queried + assert mock_get_top_organisations_from_local_db.call_count == 0 + assert mock_get_top_organisations.call_count == 0 + + # And the existing cache values are preserved (not zeroed out) + organisation.subscription_information_cache.refresh_from_db() + assert organisation.subscription_information_cache.api_calls_24h == 100 + assert organisation.subscription_information_cache.api_calls_7d == 700 + assert organisation.subscription_information_cache.api_calls_30d == 3000 diff --git a/api/tests/unit/organisations/test_unit_organisations_tasks.py b/api/tests/unit/organisations/test_unit_organisations_tasks.py index 6ecba9230b49..c422b9133390 100644 --- a/api/tests/unit/organisations/test_unit_organisations_tasks.py +++ b/api/tests/unit/organisations/test_unit_organisations_tasks.py @@ -34,6 +34,7 @@ MAX_API_CALLS_IN_FREE_PLAN, MAX_SEATS_IN_FREE_PLAN, SCALE_UP, + SubscriptionCacheEntity, ) from organisations.subscriptions.xero.metadata import XeroSubscriptionMetadata from organisations.task_helpers import ( @@ -50,6 +51,9 @@ send_org_over_limit_alert, send_org_subscription_cancelled_alert, unrestrict_after_api_limit_grace_period_is_stale, + update_organisation_subscription_information_api_usage_cache, + update_organisation_subscription_information_cache, + update_organisation_subscription_information_cache_recurring, ) from tests.types import EnableFeaturesFixture from users.models import FFAdminUser @@ -2148,3 +2152,52 @@ def test_register_recurring_tasks__alerting_enabled__registers_all_tasks( call(restrict_use_due_to_api_limit_grace_period_over), call(unrestrict_after_api_limit_grace_period_is_stale), ] + + +def test_update_organisation_subscription_information_cache_recurring__called__delegates_to_cache_task( + mocker: MockerFixture, +) -> None: + # Given + mock_update = mocker.patch( + "organisations.tasks.update_organisation_subscription_information_cache" + ) + + # When + update_organisation_subscription_information_cache_recurring() + + # Then + assert mock_update.call_args_list == [call()] + + +def test_update_organisation_subscription_information_api_usage_cache__called__calls_update_caches_with_api_usage( + mocker: MockerFixture, +) -> None: + # Given + mock_update_caches = mocker.patch( + "organisations.tasks.subscription_info_cache.update_caches" + ) + + # When + update_organisation_subscription_information_api_usage_cache() + + # Then + assert mock_update_caches.call_args_list == [ + call((SubscriptionCacheEntity.API_USAGE,)) + ] + + +def test_update_organisation_subscription_information_cache__called__calls_update_caches_with_chargebee_and_api_usage( + mocker: MockerFixture, +) -> None: + # Given + mock_update_caches = mocker.patch( + "organisations.tasks.subscription_info_cache.update_caches" + ) + + # When + update_organisation_subscription_information_cache() + + # Then + assert mock_update_caches.call_args_list == [ + call((SubscriptionCacheEntity.CHARGEBEE, SubscriptionCacheEntity.API_USAGE)) + ] From 8eaea9e4676c7f897ec5c71c61c9c4e4883343aa Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 24 Mar 2026 12:18:36 -0300 Subject: [PATCH 5/7] Fix coverage --- .../test_unit_organisations_tasks.py | 8 +++++--- .../test_unit_sales_dashboard_views.py | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/api/tests/unit/organisations/test_unit_organisations_tasks.py b/api/tests/unit/organisations/test_unit_organisations_tasks.py index c422b9133390..219bc50470a1 100644 --- a/api/tests/unit/organisations/test_unit_organisations_tasks.py +++ b/api/tests/unit/organisations/test_unit_organisations_tasks.py @@ -2166,7 +2166,7 @@ def test_update_organisation_subscription_information_cache_recurring__called__d update_organisation_subscription_information_cache_recurring() # Then - assert mock_update.call_args_list == [call()] + assert mock_update.call_args_list == [mocker.call()] def test_update_organisation_subscription_information_api_usage_cache__called__calls_update_caches_with_api_usage( @@ -2182,7 +2182,7 @@ def test_update_organisation_subscription_information_api_usage_cache__called__c # Then assert mock_update_caches.call_args_list == [ - call((SubscriptionCacheEntity.API_USAGE,)) + mocker.call((SubscriptionCacheEntity.API_USAGE,)) ] @@ -2199,5 +2199,7 @@ def test_update_organisation_subscription_information_cache__called__calls_updat # Then assert mock_update_caches.call_args_list == [ - call((SubscriptionCacheEntity.CHARGEBEE, SubscriptionCacheEntity.API_USAGE)) + mocker.call( + (SubscriptionCacheEntity.CHARGEBEE, SubscriptionCacheEntity.API_USAGE) + ) ] diff --git a/api/tests/unit/sales_dashboard/test_unit_sales_dashboard_views.py b/api/tests/unit/sales_dashboard/test_unit_sales_dashboard_views.py index 4e093abcc1ea..5967f0108b2f 100644 --- a/api/tests/unit/sales_dashboard/test_unit_sales_dashboard_views.py +++ b/api/tests/unit/sales_dashboard/test_unit_sales_dashboard_views.py @@ -318,6 +318,26 @@ def test_end_trial__organisation_in_trial__reverts_to_free_plan( ) +@pytest.mark.django_db +def test_trigger_update_organisation_subscription_information_api_usage_cache__called__enqueues_task( + superuser_client: APIClient, + mocker: MockerFixture, +) -> None: + # Given + mock_update = mocker.patch( + "sales_dashboard.views.update_organisation_subscription_information_api_usage_cache" + ) + + # When + response = superuser_client.get( + "/sales-dashboard/update-organisation-subscription-information-influx-cache" + ) + + # Then + assert response.status_code == 302 + assert mock_update.delay.call_args_list == [mocker.call()] + + @pytest.mark.django_db def test_list_organisations__empty_organisation__returns_zero_counts( rf: RequestFactory, From 48f96d921f13610d58dd1c7c0fef79683b84c8b9 Mon Sep 17 00:00:00 2001 From: Evandro Myller Date: Tue, 24 Mar 2026 12:48:40 -0300 Subject: [PATCH 6/7] Improve interface --- api/organisations/subscription_info_cache.py | 2 +- api/organisations/tasks.py | 4 ++-- ...it_organisations_subscription_info_cache.py | 18 +++++------------- .../test_unit_organisations_tasks.py | 4 ++-- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/api/organisations/subscription_info_cache.py b/api/organisations/subscription_info_cache.py index cd14f47a9629..086b6e621e1a 100644 --- a/api/organisations/subscription_info_cache.py +++ b/api/organisations/subscription_info_cache.py @@ -16,7 +16,7 @@ ] -def update_caches(update_cache_entities: typing.Tuple[SubscriptionCacheEntity, ...]): # type: ignore[no-untyped-def] +def update_caches(*update_cache_entities: SubscriptionCacheEntity) -> None: """ Update the cache objects for an update_cache_entity in the database. """ diff --git a/api/organisations/tasks.py b/api/organisations/tasks.py index 774a13d71ba6..43c5dca0b278 100644 --- a/api/organisations/tasks.py +++ b/api/organisations/tasks.py @@ -89,13 +89,13 @@ def update_organisation_subscription_information_cache_recurring() -> None: @register_task_handler() def update_organisation_subscription_information_api_usage_cache() -> None: - subscription_info_cache.update_caches((SubscriptionCacheEntity.API_USAGE,)) + subscription_info_cache.update_caches(SubscriptionCacheEntity.API_USAGE) @register_task_handler(timeout=timedelta(minutes=5)) def update_organisation_subscription_information_cache() -> None: subscription_info_cache.update_caches( - (SubscriptionCacheEntity.CHARGEBEE, SubscriptionCacheEntity.API_USAGE) + SubscriptionCacheEntity.CHARGEBEE, SubscriptionCacheEntity.API_USAGE ) diff --git a/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py b/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py index 338f5045d22c..2f8853ff4045 100644 --- a/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py +++ b/api/tests/unit/organisations/test_unit_organisations_subscription_info_cache.py @@ -48,11 +48,7 @@ def test_update_caches__with_usage_data__populates_cache_correctly( # type: ign mocked_get_subscription_metadata.return_value = chargebee_metadata # When - subscription_cache_entities = ( - SubscriptionCacheEntity.API_USAGE, - SubscriptionCacheEntity.CHARGEBEE, - ) - update_caches(subscription_cache_entities) + update_caches(SubscriptionCacheEntity.API_USAGE, SubscriptionCacheEntity.CHARGEBEE) # Then assert ( @@ -118,11 +114,7 @@ def test_update_caches__no_usage_data__resets_cache_to_zero( mocked_get_subscription_metadata.return_value = chargebee_metadata # When - subscription_cache_entities = ( - SubscriptionCacheEntity.API_USAGE, - SubscriptionCacheEntity.CHARGEBEE, - ) - update_caches(subscription_cache_entities) + update_caches(SubscriptionCacheEntity.API_USAGE, SubscriptionCacheEntity.CHARGEBEE) # Then organisation.refresh_from_db() @@ -159,7 +151,7 @@ def test_update_caches__postgres_analytics__populates_cache_correctly( ) # When - update_caches((SubscriptionCacheEntity.API_USAGE,)) + update_caches(SubscriptionCacheEntity.API_USAGE) # Then organisation.refresh_from_db() @@ -197,7 +189,7 @@ def test_update_caches__postgres_and_influx_configured__prefers_postgres( ) # When - update_caches((SubscriptionCacheEntity.API_USAGE,)) + update_caches(SubscriptionCacheEntity.API_USAGE) # Then assert mocked_get_top_organisations_from_local_db.call_count == 3 @@ -228,7 +220,7 @@ def test_update_caches__no_analytics_source_configured__skips_api_usage_update( ) # When - update_caches((SubscriptionCacheEntity.API_USAGE,)) + update_caches(SubscriptionCacheEntity.API_USAGE) # Then — neither analytics source was queried assert mock_get_top_organisations_from_local_db.call_count == 0 diff --git a/api/tests/unit/organisations/test_unit_organisations_tasks.py b/api/tests/unit/organisations/test_unit_organisations_tasks.py index 219bc50470a1..e6414edb401a 100644 --- a/api/tests/unit/organisations/test_unit_organisations_tasks.py +++ b/api/tests/unit/organisations/test_unit_organisations_tasks.py @@ -2182,7 +2182,7 @@ def test_update_organisation_subscription_information_api_usage_cache__called__c # Then assert mock_update_caches.call_args_list == [ - mocker.call((SubscriptionCacheEntity.API_USAGE,)) + mocker.call(SubscriptionCacheEntity.API_USAGE) ] @@ -2200,6 +2200,6 @@ def test_update_organisation_subscription_information_cache__called__calls_updat # Then assert mock_update_caches.call_args_list == [ mocker.call( - (SubscriptionCacheEntity.CHARGEBEE, SubscriptionCacheEntity.API_USAGE) + SubscriptionCacheEntity.CHARGEBEE, SubscriptionCacheEntity.API_USAGE ) ] From 708fe6a58fe435a861670f11bf27a252df354694 Mon Sep 17 00:00:00 2001 From: Evandro Myller <22429+emyller@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:09:15 -0300 Subject: [PATCH 7/7] Fix note Co-authored-by: Matthew Elwell --- api/organisations/subscription_info_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/organisations/subscription_info_cache.py b/api/organisations/subscription_info_cache.py index 086b6e621e1a..51b2c6e865c3 100644 --- a/api/organisations/subscription_info_cache.py +++ b/api/organisations/subscription_info_cache.py @@ -36,7 +36,7 @@ def update_caches(*update_cache_entities: SubscriptionCacheEntity) -> None: if ( SubscriptionCacheEntity.API_USAGE in update_cache_entities # NOTE: SubscriptionCacheEntity.INFLUX is superseded, but must live - # briefly for the sake of task processor continuity during the release. + # forever for the sake of task processor continuity during version updates. # TODO: https://github.com/Flagsmith/flagsmith/pull/7024 or SubscriptionCacheEntity.INFLUX in update_cache_entities ):