From 40895d84ee6a537ee32da84795a25af03f471f8d Mon Sep 17 00:00:00 2001 From: James Kiger Date: Wed, 4 Dec 2024 13:24:25 -0500 Subject: [PATCH 1/4] Update usage serializers to return only data for current billing period, regardless of whether it is monthly or yearly and update tests accordingly --- kobo/apps/organizations/utils.py | 37 +----- kobo/apps/organizations/views.py | 31 ++--- .../tests/api/v2/test_api.py | 18 +-- .../stripe/tests/test_organization_usage.py | 117 ++++++++++++------ kpi/serializers/v2/service_usage.py | 56 +++------ kpi/tests/api/v2/test_api_asset_usage.py | 26 +++- kpi/tests/api/v2/test_api_service_usage.py | 24 ++-- kpi/tests/test_usage_calculator.py | 26 ++-- kpi/utils/usage_calculator.py | 40 ++---- kpi/views/v2/asset_usage.py | 6 +- kpi/views/v2/service_usage.py | 15 +-- 11 files changed, 173 insertions(+), 223 deletions(-) diff --git a/kobo/apps/organizations/utils.py b/kobo/apps/organizations/utils.py index 954162dd5e..b087304649 100644 --- a/kobo/apps/organizations/utils.py +++ b/kobo/apps/organizations/utils.py @@ -10,8 +10,8 @@ from kpi.models.object_permission import ObjectPermission -def get_monthly_billing_dates(organization: Union['Organization', None]): - """Returns start and end dates of an organization's monthly billing cycle""" +def get_billing_dates(organization: Union['Organization', None]): + """Returns start and end dates of an organization's billing cycle.""" now = timezone.now().replace(tzinfo=ZoneInfo('UTC')) first_of_this_month = datetime(now.year, now.month, 1, tzinfo=ZoneInfo('UTC')) @@ -48,7 +48,6 @@ def get_monthly_billing_dates(organization: Union['Organization', None]): if not billing_details.get('billing_cycle_anchor'): return first_of_this_month, first_of_next_month - # Subscription is billed monthly, use the current billing period dates if billing_details.get('recurring_interval') == 'month': period_start = billing_details.get('current_period_start').replace( tzinfo=ZoneInfo('UTC') @@ -58,31 +57,6 @@ def get_monthly_billing_dates(organization: Union['Organization', None]): ) return period_start, period_end - # Subscription is billed yearly - count backwards from the end of the - # current billing year - period_start = billing_details.get('current_period_end').replace( - tzinfo=ZoneInfo('UTC') - ) - while period_start > now: - period_start -= relativedelta(months=1) - period_end = period_start + relativedelta(months=1) - return period_start, period_end - - -def get_yearly_billing_dates(organization: Union['Organization', None]): - """Returns start and end dates of an organization's annual billing cycle""" - now = timezone.now().replace(tzinfo=ZoneInfo('UTC')) - first_of_this_year = datetime(now.year, 1, 1, tzinfo=ZoneInfo('UTC')) - first_of_next_year = first_of_this_year + relativedelta(years=1) - - if not organization: - return first_of_this_year, first_of_next_year - if not (billing_details := organization.active_subscription_billing_details()): - return first_of_this_year, first_of_next_year - if not (anchor_date := billing_details.get('billing_cycle_anchor')): - return first_of_this_year, first_of_next_year - - # Subscription is billed yearly, use the dates from the subscription if billing_details.get('recurring_interval') == 'year': period_start = billing_details.get('current_period_start').replace( tzinfo=ZoneInfo('UTC') @@ -92,12 +66,7 @@ def get_yearly_billing_dates(organization: Union['Organization', None]): ) return period_start, period_end - # Subscription is monthly, calculate this year's start based on anchor date - period_start = anchor_date.replace(tzinfo=ZoneInfo('UTC')) + relativedelta(years=1) - while period_start < now: - anchor_date += relativedelta(years=1) - period_end = period_start + relativedelta(years=1) - return period_start, period_end + return first_of_this_month, first_of_next_month def revoke_org_asset_perms(organization: Organization, user_ids: list[int]): diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index 9c3d4634ee..fbf862147f 100644 --- a/kobo/apps/organizations/views.py +++ b/kobo/apps/organizations/views.py @@ -1,13 +1,6 @@ from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg -from django.db.models import ( - QuerySet, - Case, - When, - Value, - CharField, - OuterRef, -) +from django.db.models import Case, CharField, OuterRef, QuerySet, Value, When from django.db.models.expressions import Exists from django.utils.http import http_date from rest_framework import status, viewsets @@ -25,11 +18,11 @@ ) from kpi.utils.object_permission import get_database_user from kpi.views.v2.asset import AssetViewSet +from ..accounts.mfa.models import MfaMethod +from ..stripe.constants import ACTIVE_STRIPE_STATUSES from .models import Organization, OrganizationOwner, OrganizationUser from .permissions import HasOrgRolePermission, IsOrgAdminPermission from .serializers import OrganizationSerializer, OrganizationUserSerializer -from ..accounts.mfa.models import MfaMethod -from ..stripe.constants import ACTIVE_STRIPE_STATUSES class OrganizationAssetViewSet(AssetViewSet): @@ -128,22 +121,18 @@ def service_usage(self, request, pk=None, *args, **kwargs): > curl -X GET https://[kpi]/api/v2/organizations/{organization_id}/service_usage/ > { > "total_nlp_usage": { - > "asr_seconds_current_month": {integer}, - > "asr_seconds_current_year": {integer}, + > "asr_seconds_current_period": {integer}, > "asr_seconds_all_time": {integer}, - > "mt_characters_current_month": {integer}, - > "mt_characters_current_year": {integer}, + > "mt_characters_current_period": {integer}, > "mt_characters_all_time": {integer}, > }, > "total_storage_bytes": {integer}, > "total_submission_count": { - > "current_month": {integer}, - > "current_year": {integer}, + > "current_period": {integer}, > "all_time": {integer}, > }, - > "current_month_start": {string (date), ISO format}, - > "current_year_start": {string (date), ISO format}, - > "billing_period_end": {string (date), ISO format}|{None}, + > "current_period_start": {string (date), ISO format}, + > "current_period_end": {string (date), ISO format}|{None}, > "last_updated": {string (date), ISO format}, > } ### CURRENT ENDPOINT @@ -189,7 +178,7 @@ def asset_usage(self, request, pk=None, *args, **kwargs): > "asset_type": {string}, > "asset": {asset_url}, > "asset_name": {string}, - > "nlp_usage_current_month": { + > "nlp_usage_current_period": { > "total_asr_seconds": {integer}, > "total_mt_characters": {integer}, > } @@ -198,7 +187,7 @@ def asset_usage(self, request, pk=None, *args, **kwargs): > "total_mt_characters": {integer}, > } > "storage_bytes": {integer}, - > "submission_count_current_month": {integer}, + > "submission_count_current_period": {integer}, > "submission_count_all_time": {integer}, > "deployment_status": {string}, > },{...} diff --git a/kobo/apps/project_ownership/tests/api/v2/test_api.py b/kobo/apps/project_ownership/tests/api/v2/test_api.py index 7abafd1a42..2fd4379ab5 100644 --- a/kobo/apps/project_ownership/tests/api/v2/test_api.py +++ b/kobo/apps/project_ownership/tests/api/v2/test_api.py @@ -367,35 +367,29 @@ def test_account_usage_transferred_to_new_user(self): today = timezone.now() expected_data = { 'total_nlp_usage': { - 'asr_seconds_current_year': 120, - 'mt_characters_current_year': 1000, - 'asr_seconds_current_month': 120, - 'mt_characters_current_month': 1000, + 'asr_seconds_current_period': 120, + 'mt_characters_current_period': 1000, 'asr_seconds_all_time': 120, 'mt_characters_all_time': 1000, }, 'total_storage_bytes': 191642, 'total_submission_count': { 'all_time': 1, - 'current_year': 1, - 'current_month': 1, + 'current_period': 1, }, } expected_empty_data = { 'total_nlp_usage': { - 'asr_seconds_current_year': 0, - 'mt_characters_current_year': 0, - 'asr_seconds_current_month': 0, - 'mt_characters_current_month': 0, + 'asr_seconds_current_period': 0, + 'mt_characters_current_period': 0, 'asr_seconds_all_time': 0, 'mt_characters_all_time': 0, }, 'total_storage_bytes': 0, 'total_submission_count': { 'all_time': 0, - 'current_year': 0, - 'current_month': 0, + 'current_period': 0, }, } diff --git a/kobo/apps/stripe/tests/test_organization_usage.py b/kobo/apps/stripe/tests/test_organization_usage.py index a349361bff..01c82ef65a 100644 --- a/kobo/apps/stripe/tests/test_organization_usage.py +++ b/kobo/apps/stripe/tests/test_organization_usage.py @@ -90,8 +90,14 @@ def test_usage_doesnt_include_org_users_without_subscription(self): """ response = self.client.get(self.detail_url) # without a plan, the user should only see their usage - assert response.data['total_submission_count']['all_time'] == self.expected_submissions_single - assert response.data['total_submission_count']['current_month'] == self.expected_submissions_single + assert ( + response.data['total_submission_count']['all_time'] + == self.expected_submissions_single + ) + assert ( + response.data['total_submission_count']['current_period'] + == self.expected_submissions_single + ) assert response.data['total_storage_bytes'] == ( self.expected_file_size() * self.expected_submissions_single ) @@ -106,8 +112,14 @@ def test_usage_for_plans_with_org_access(self): # the user should see usage for everyone in their org response = self.client.get(self.detail_url) - assert response.data['total_submission_count']['current_month'] == self.expected_submissions_multi - assert response.data['total_submission_count']['all_time'] == self.expected_submissions_multi + assert ( + response.data['total_submission_count']['current_period'] + == self.expected_submissions_multi + ) + assert ( + response.data['total_submission_count']['all_time'] + == self.expected_submissions_multi + ) assert response.data['total_storage_bytes'] == ( self.expected_file_size() * self.expected_submissions_multi ) @@ -122,8 +134,14 @@ def test_doesnt_include_org_users_with_invalid_plan(self): response = self.client.get(self.detail_url) # without the proper subscription, the user should only see their usage - assert response.data['total_submission_count']['current_month'] == self.expected_submissions_single - assert response.data['total_submission_count']['all_time'] == self.expected_submissions_single + assert ( + response.data['total_submission_count']['current_period'] + == self.expected_submissions_single + ) + assert ( + response.data['total_submission_count']['all_time'] + == self.expected_submissions_single + ) assert response.data['total_storage_bytes'] == ( self.expected_file_size() * self.expected_submissions_single ) @@ -149,8 +167,14 @@ def test_endpoint_is_cached(self): generate_mmo_subscription(self.organization) first_response = self.client.get(self.detail_url) - assert first_response.data['total_submission_count']['current_month'] == self.expected_submissions_multi - assert first_response.data['total_submission_count']['all_time'] == self.expected_submissions_multi + assert ( + first_response.data['total_submission_count']['current_period'] + == self.expected_submissions_multi + ) + assert ( + first_response.data['total_submission_count']['all_time'] + == self.expected_submissions_multi + ) assert first_response.data['total_storage_bytes'] == ( self.expected_file_size() * self.expected_submissions_multi ) @@ -161,8 +185,14 @@ def test_endpoint_is_cached(self): # make sure the second request doesn't reflect the additional submissions response = self.client.get(self.detail_url) - assert response.data['total_submission_count']['current_month'] == self.expected_submissions_multi - assert response.data['total_submission_count']['all_time'] == self.expected_submissions_multi + assert ( + response.data['total_submission_count']['current_period'] + == self.expected_submissions_multi + ) + assert ( + response.data['total_submission_count']['all_time'] + == self.expected_submissions_multi + ) assert response.data['total_storage_bytes'] == ( self.expected_file_size() * self.expected_submissions_multi ) @@ -204,12 +234,11 @@ def test_default_plan_period(self): first_of_month = datetime(now.year, now.month, 1, tzinfo=ZoneInfo('UTC')) first_of_next_month = first_of_month + relativedelta(months=1) - assert response.data['total_submission_count']['current_month'] == num_submissions assert ( - response.data['current_month_start'] - == first_of_month.isoformat() + response.data['total_submission_count']['current_period'] == num_submissions ) - assert response.data['current_month_end'] == first_of_next_month.isoformat() + assert response.data['current_period_start'] == first_of_month.isoformat() + assert response.data['current_period_end'] == first_of_next_month.isoformat() def test_monthly_plan_period(self): """ @@ -225,12 +254,14 @@ def test_monthly_plan_period(self): response = self.client.get(self.detail_url) assert ( - response.data['total_submission_count']['current_month'] - == num_submissions + response.data['total_submission_count']['current_period'] == num_submissions + ) + assert ( + response.data['current_period_start'] + == subscription.current_period_start.isoformat() ) - assert response.data['current_month_start'] == subscription.current_period_start.isoformat() assert ( - response.data['current_month_end'] + response.data['current_period_end'] == subscription.current_period_end.isoformat() ) @@ -248,12 +279,14 @@ def test_annual_plan_period(self): response = self.client.get(self.detail_url) assert ( - response.data['total_submission_count']['current_year'] - == num_submissions + response.data['total_submission_count']['current_period'] == num_submissions ) - assert response.data['current_year_start'] == subscription.current_period_start.isoformat() assert ( - response.data['current_year_end'] + response.data['current_period_start'] + == subscription.current_period_start.isoformat() + ) + assert ( + response.data['current_period_end'] == subscription.current_period_end.isoformat() ) @@ -277,12 +310,12 @@ def test_plan_canceled_this_month(self): response = self.client.get(self.detail_url) + assert response.data['total_submission_count']['current_period'] == 0 + assert response.data['current_period_start'] == canceled_at.isoformat() assert ( - response.data['total_submission_count']['current_month'] - == 0 + response.data['current_period_end'] + == current_billing_period_end.isoformat() ) - assert response.data['current_month_start'] == canceled_at.isoformat() - assert response.data['current_month_end'] == current_billing_period_end.isoformat() def test_plan_canceled_last_month(self): subscription = generate_plan_subscription(self.organization, age_days=60) @@ -302,12 +335,14 @@ def test_plan_canceled_last_month(self): response = self.client.get(self.detail_url) assert ( - response.data['total_submission_count']['current_month'] - == num_submissions + response.data['total_submission_count']['current_period'] == num_submissions ) - assert response.data['current_month_start'] == current_billing_period_start.isoformat() assert ( - response.data['current_month_end'] + response.data['current_period_start'] + == current_billing_period_start.isoformat() + ) + assert ( + response.data['current_period_end'] == current_billing_period_end.isoformat() ) @@ -334,15 +369,15 @@ def test_plan_canceled_edge_date(self): with freeze_time(frozen_datetime_now): response = self.client.get(self.detail_url) - current_month_start = datetime.fromisoformat( - response.data['current_month_start'] + current_period_start = datetime.fromisoformat( + response.data['current_period_start'] ) - current_month_end = datetime.fromisoformat(response.data['current_month_end']) + current_period_end = datetime.fromisoformat(response.data['current_period_end']) - assert current_month_start.month == cancel_date.month - assert current_month_start.day == cancel_date.day - assert current_month_end.month == 9 - assert current_month_end.day == 30 + assert current_period_start.month == cancel_date.month + assert current_period_start.day == cancel_date.day + assert current_period_end.month == 9 + assert current_period_end.day == 30 def test_multiple_canceled_plans(self): """ @@ -384,13 +419,15 @@ def test_multiple_canceled_plans(self): response = self.client.get(self.detail_url) - assert response.data['total_submission_count']['current_month'] == num_submissions assert ( - response.data['current_month_start'] + response.data['total_submission_count']['current_period'] == num_submissions + ) + assert ( + response.data['current_period_start'] == current_billing_period_start.isoformat() ) assert ( - response.data['current_month_end'] + response.data['current_period_end'] == current_billing_period_end.isoformat() ) diff --git a/kpi/serializers/v2/service_usage.py b/kpi/serializers/v2/service_usage.py index 36adab02ff..6ca9dcf147 100644 --- a/kpi/serializers/v2/service_usage.py +++ b/kpi/serializers/v2/service_usage.py @@ -2,10 +2,7 @@ from rest_framework.fields import empty from kobo.apps.organizations.models import Organization -from kobo.apps.organizations.utils import ( - get_monthly_billing_dates, - get_yearly_billing_dates, -) +from kobo.apps.organizations.utils import get_billing_dates from kpi.deployment_backends.openrosa_backend import OpenRosaDeploymentBackend from kpi.models.asset import Asset from kpi.utils.usage_calculator import ServiceUsageCalculator @@ -17,12 +14,10 @@ class AssetUsageSerializer(serializers.HyperlinkedModelSerializer): view_name='asset-detail', ) asset__name = serializers.ReadOnlyField(source='name') - nlp_usage_current_month = serializers.SerializerMethodField() - nlp_usage_current_year = serializers.SerializerMethodField() + nlp_usage_current_period = serializers.SerializerMethodField() nlp_usage_all_time = serializers.SerializerMethodField() storage_bytes = serializers.SerializerMethodField() - submission_count_current_month = serializers.SerializerMethodField() - submission_count_current_year = serializers.SerializerMethodField() + submission_count_current_period = serializers.SerializerMethodField() submission_count_all_time = serializers.SerializerMethodField() class Meta: @@ -31,39 +26,28 @@ class Meta: fields = ( 'asset', 'asset__name', - 'nlp_usage_current_month', - 'nlp_usage_current_year', + 'nlp_usage_current_period', 'nlp_usage_all_time', 'storage_bytes', - 'submission_count_current_month', - 'submission_count_current_year', + 'submission_count_current_period', 'submission_count_all_time', ) def __init__(self, instance=None, data=empty, **kwargs): super().__init__(instance=instance, data=data, **kwargs) organization = self.context.get('organization') - self._month_start, _ = get_monthly_billing_dates(organization) - self._year_start, _ = get_yearly_billing_dates(organization) + self._period_start, _ = get_billing_dates(organization) - def get_nlp_usage_current_month(self, asset): - return self._get_nlp_tracking_data(asset, self._month_start) - - def get_nlp_usage_current_year(self, asset): - return self._get_nlp_tracking_data(asset, self._year_start) + def get_nlp_usage_current_period(self, asset): + return self._get_nlp_tracking_data(asset, self._period_start) def get_nlp_usage_all_time(self, asset): return self._get_nlp_tracking_data(asset) - def get_submission_count_current_month(self, asset): - if not asset.has_deployment: - return 0 - return asset.deployment.submission_count_since_date(self._month_start) - - def get_submission_count_current_year(self, asset): + def get_submission_count_current_period(self, asset): if not asset.has_deployment: return 0 - return asset.deployment.submission_count_since_date(self._year_start) + return asset.deployment.submission_count_since_date(self._period_start) def get_submission_count_all_time(self, asset): if not asset.has_deployment: @@ -103,10 +87,8 @@ class ServiceUsageSerializer(serializers.Serializer): total_nlp_usage = serializers.SerializerMethodField() total_storage_bytes = serializers.SerializerMethodField() total_submission_count = serializers.SerializerMethodField() - current_month_start = serializers.SerializerMethodField() - current_month_end = serializers.SerializerMethodField() - current_year_start = serializers.SerializerMethodField() - current_year_end = serializers.SerializerMethodField() + current_period_start = serializers.SerializerMethodField() + current_period_end = serializers.SerializerMethodField() last_updated = serializers.SerializerMethodField() def __init__(self, instance=None, data=empty, **kwargs): @@ -120,17 +102,11 @@ def __init__(self, instance=None, data=empty, **kwargs): ).first() self.calculator = ServiceUsageCalculator(instance, organization) - def get_current_month_end(self, user): - return self.calculator.current_month_end.isoformat() - - def get_current_month_start(self, user): - return self.calculator.current_month_start.isoformat() - - def get_current_year_end(self, user): - return self.calculator.current_year_end.isoformat() + def get_current_period_end(self, user): + return self.calculator.current_period_end.isoformat() - def get_current_year_start(self, user): - return self.calculator.current_year_start.isoformat() + def get_current_period_start(self, user): + return self.calculator.current_period_start.isoformat() def get_last_updated(self, user): return self.calculator.get_last_updated().isoformat() diff --git a/kpi/tests/api/v2/test_api_asset_usage.py b/kpi/tests/api/v2/test_api_asset_usage.py index ed5197b227..af746fd322 100644 --- a/kpi/tests/api/v2/test_api_asset_usage.py +++ b/kpi/tests/api/v2/test_api_asset_usage.py @@ -171,10 +171,26 @@ def test_check_api_response(self): assert response.status_code == status.HTTP_200_OK assert len(response.data['results']) == 1 assert response.data['results'][0]['asset__name'] == '' - assert response.data['results'][0]['nlp_usage_current_month']['total_nlp_asr_seconds'] == 4586 - assert response.data['results'][0]['nlp_usage_current_month']['total_nlp_mt_characters'] == 5473 - assert response.data['results'][0]['nlp_usage_all_time']['total_nlp_asr_seconds'] == 4728 - assert response.data['results'][0]['nlp_usage_all_time']['total_nlp_mt_characters'] == 6726 + assert ( + response.data['results'][0]['nlp_usage_current_period'][ + 'total_nlp_asr_seconds' + ] + == 4586 + ) + assert ( + response.data['results'][0]['nlp_usage_current_period'][ + 'total_nlp_mt_characters' + ] + == 5473 + ) + assert ( + response.data['results'][0]['nlp_usage_all_time']['total_nlp_asr_seconds'] + == 4728 + ) + assert ( + response.data['results'][0]['nlp_usage_all_time']['total_nlp_mt_characters'] + == 6726 + ) assert ( response.data['results'][0]['storage_bytes'] == ( @@ -183,7 +199,7 @@ def test_check_api_response(self): ) * 2 # __add_submissions() adds 2 submissions ) - assert response.data['results'][0]['submission_count_current_month'] == 2 + assert response.data['results'][0]['submission_count_current_period'] == 2 assert response.data['results'][0]['submission_count_all_time'] == 2 def test_no_data(self): diff --git a/kpi/tests/api/v2/test_api_service_usage.py b/kpi/tests/api/v2/test_api_service_usage.py index 60a159cc29..50204bf81d 100644 --- a/kpi/tests/api/v2/test_api_service_usage.py +++ b/kpi/tests/api/v2/test_api_service_usage.py @@ -29,20 +29,12 @@ def test_check_api_response(self): response = self.client.get(url) assert response.status_code == status.HTTP_200_OK - assert response.data['total_submission_count']['current_month'] == 1 + assert response.data['total_submission_count']['current_period'] == 1 assert response.data['total_submission_count']['all_time'] == 1 - assert ( - response.data['total_nlp_usage']['asr_seconds_current_month'] - == 4586 - ) + assert response.data['total_nlp_usage']['asr_seconds_current_period'] == 4586 assert response.data['total_nlp_usage']['asr_seconds_all_time'] == 4728 - assert ( - response.data['total_nlp_usage']['mt_characters_current_month'] - == 5473 - ) - assert ( - response.data['total_nlp_usage']['mt_characters_all_time'] == 6726 - ) + assert response.data['total_nlp_usage']['mt_characters_current_period'] == 5473 + assert response.data['total_nlp_usage']['mt_characters_all_time'] == 6726 assert response.data['total_storage_bytes'] == self.expected_file_size() def test_multiple_forms(self): @@ -58,7 +50,7 @@ def test_multiple_forms(self): url = reverse(self._get_endpoint('service-usage-list')) response = self.client.get(url) - assert response.data['total_submission_count']['current_month'] == 3 + assert response.data['total_submission_count']['current_period'] == 3 assert response.data['total_submission_count']['all_time'] == 3 assert response.data['total_storage_bytes'] == ( self.expected_file_size() * 3 @@ -85,7 +77,7 @@ def test_service_usages_with_projects_in_trash_bin(self): url = reverse(self._get_endpoint('service-usage-list')) response = self.client.get(url) - assert response.data['total_submission_count']['current_month'] == 3 + assert response.data['total_submission_count']['current_period'] == 3 assert response.data['total_submission_count']['all_time'] == 3 assert response.data['total_storage_bytes'] == 0 @@ -97,7 +89,7 @@ def test_no_data(self): url = reverse(self._get_endpoint('service-usage-list')) response = self.client.get(url) assert response.status_code == status.HTTP_200_OK - assert response.data['total_submission_count']['current_month'] == 0 + assert response.data['total_submission_count']['current_period'] == 0 assert response.data['total_submission_count']['all_time'] == 0 assert response.data['total_nlp_usage']['asr_seconds_all_time'] == 0 assert response.data['total_storage_bytes'] == 0 @@ -130,7 +122,7 @@ def test_no_deployment(self): url = reverse(self._get_endpoint('service-usage-list')) response = self.client.get(url) assert response.status_code == status.HTTP_200_OK - assert response.data['total_submission_count']['current_month'] == 0 + assert response.data['total_submission_count']['current_period'] == 0 assert response.data['total_submission_count']['all_time'] == 0 assert response.data['total_nlp_usage']['asr_seconds_all_time'] == 0 assert response.data['total_storage_bytes'] == 0 diff --git a/kpi/tests/test_usage_calculator.py b/kpi/tests/test_usage_calculator.py index 82c3afc6f7..fc1bc2d5a5 100644 --- a/kpi/tests/test_usage_calculator.py +++ b/kpi/tests/test_usage_calculator.py @@ -167,20 +167,20 @@ def test_disable_cache(self): self.add_nlp_trackers() nlp_usage_B = calculator.get_nlp_usage_counters() assert ( - 2 * nlp_usage_A['asr_seconds_current_month'] - == nlp_usage_B['asr_seconds_current_month'] + 2 * nlp_usage_A['asr_seconds_current_period'] + == nlp_usage_B['asr_seconds_current_period'] ) assert ( - 2 * nlp_usage_A['mt_characters_current_month'] - == nlp_usage_B['mt_characters_current_month'] + 2 * nlp_usage_A['mt_characters_current_period'] + == nlp_usage_B['mt_characters_current_period'] ) def test_nlp_usage_counters(self): calculator = ServiceUsageCalculator(self.anotheruser, None) nlp_usage = calculator.get_nlp_usage_counters() - assert nlp_usage['asr_seconds_current_month'] == 4586 + assert nlp_usage['asr_seconds_current_period'] == 4586 assert nlp_usage['asr_seconds_all_time'] == 4728 - assert nlp_usage['mt_characters_current_month'] == 5473 + assert nlp_usage['mt_characters_current_period'] == 5473 assert nlp_usage['mt_characters_all_time'] == 6726 def test_no_data(self): @@ -188,12 +188,12 @@ def test_no_data(self): nlp_usage = calculator.get_nlp_usage_counters() submission_counters = calculator.get_submission_counters() - assert nlp_usage['asr_seconds_current_month'] == 0 + assert nlp_usage['asr_seconds_current_period'] == 0 assert nlp_usage['asr_seconds_all_time'] == 0 - assert nlp_usage['mt_characters_current_month'] == 0 + assert nlp_usage['mt_characters_current_period'] == 0 assert nlp_usage['mt_characters_all_time'] == 0 assert calculator.get_storage_usage() == 0 - assert submission_counters['current_month'] == 0 + assert submission_counters['current_period'] == 0 assert submission_counters['all_time'] == 0 @override_settings(STRIPE_ENABLED=True) @@ -205,13 +205,13 @@ def test_organization_setup(self): calculator = ServiceUsageCalculator(self.someuser, organization) submission_counters = calculator.get_submission_counters() - assert submission_counters['current_month'] == 5 + assert submission_counters['current_period'] == 5 assert submission_counters['all_time'] == 5 nlp_usage = calculator.get_nlp_usage_counters() - assert nlp_usage['asr_seconds_current_month'] == 4586 + assert nlp_usage['asr_seconds_current_period'] == 4586 assert nlp_usage['asr_seconds_all_time'] == 4728 - assert nlp_usage['mt_characters_current_month'] == 5473 + assert nlp_usage['mt_characters_current_period'] == 5473 assert nlp_usage['mt_characters_all_time'] == 6726 assert calculator.get_storage_usage() == 5 * self.expected_file_size() @@ -226,5 +226,5 @@ def test_storage_usage(self): def test_submission_counters(self): calculator = ServiceUsageCalculator(self.anotheruser, None) submission_counters = calculator.get_submission_counters() - assert submission_counters['current_month'] == 5 + assert submission_counters['current_period'] == 5 assert submission_counters['all_time'] == 5 diff --git a/kpi/utils/usage_calculator.py b/kpi/utils/usage_calculator.py index 12ea46b8dc..c6082f51c4 100644 --- a/kpi/utils/usage_calculator.py +++ b/kpi/utils/usage_calculator.py @@ -9,10 +9,7 @@ from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.openrosa.apps.logger.models import DailyXFormSubmissionCounter, XForm -from kobo.apps.organizations.utils import ( - get_monthly_billing_dates, - get_yearly_billing_dates, -) +from kobo.apps.organizations.utils import get_billing_dates from kobo.apps.stripe.constants import ACTIVE_STRIPE_STATUSES from kpi.utils.cache import CachedClass, cached_class_property @@ -50,14 +47,10 @@ def __init__( self._user_id_query = self._filter_by_user(user_ids) now = timezone.now() - self.current_month_start, self.current_month_end = get_monthly_billing_dates( + self.current_period_start, self.current_period_end = get_billing_dates( organization ) - self.current_year_start, self.current_year_end = get_yearly_billing_dates( - organization - ) - self.current_month_filter = Q(date__range=[self.current_month_start, now]) - self.current_year_filter = Q(date__range=[self.current_year_start, now]) + self.current_period_filter = Q(date__range=[self.current_period_start, now]) self._setup_cache() def get_nlp_usage_by_type(self, usage_key: str) -> int: @@ -71,12 +64,11 @@ def get_nlp_usage_by_type(self, usage_key: str) -> int: if not billing_details: return None - interval = billing_details['recurring_interval'] nlp_usage = self.get_nlp_usage_counters() cached_usage = { - 'asr_seconds': nlp_usage[f'asr_seconds_current_{interval}'], - 'mt_characters': nlp_usage[f'mt_characters_current_{interval}'], + 'asr_seconds': nlp_usage['asr_seconds_current_period'], + 'mt_characters': nlp_usage['mt_characters_current_period'], } return cached_usage[usage_key] @@ -96,19 +88,12 @@ def get_nlp_usage_counters(self): ) .filter(self._user_id_query) .aggregate( - asr_seconds_current_year=Coalesce( - Sum('total_asr_seconds', filter=self.current_year_filter), 0 - ), - mt_characters_current_year=Coalesce( - Sum('total_mt_characters', filter=self.current_year_filter), + asr_seconds_current_period=Coalesce( + Sum('total_asr_seconds', filter=self.current_period_filter), 0, ), - asr_seconds_current_month=Coalesce( - Sum('total_asr_seconds', filter=self.current_month_filter), - 0, - ), - mt_characters_current_month=Coalesce( - Sum('total_mt_characters', filter=self.current_month_filter), + mt_characters_current_period=Coalesce( + Sum('total_mt_characters', filter=self.current_period_filter), 0, ), asr_seconds_all_time=Coalesce(Sum('total_asr_seconds'), 0), @@ -155,11 +140,8 @@ def get_submission_counters(self): .filter(self._user_id_query) .aggregate( all_time=Coalesce(Sum('counter'), 0), - current_year=Coalesce( - Sum('counter', filter=self.current_year_filter), 0 - ), - current_month=Coalesce( - Sum('counter', filter=self.current_month_filter), 0 + current_period=Coalesce( + Sum('counter', filter=self.current_period_filter), 0 ), ) ) diff --git a/kpi/views/v2/asset_usage.py b/kpi/views/v2/asset_usage.py index 932ce70262..596d03d1d1 100644 --- a/kpi/views/v2/asset_usage.py +++ b/kpi/views/v2/asset_usage.py @@ -2,9 +2,9 @@ from rest_framework.mixins import ListModelMixin from kpi.models.asset import Asset +from kpi.paginators import AssetUsagePagination from kpi.permissions import IsAuthenticated from kpi.serializers.v2.service_usage import AssetUsageSerializer -from kpi.paginators import AssetUsagePagination class AssetUsageViewSet(ListModelMixin, viewsets.GenericViewSet): @@ -27,7 +27,7 @@ class AssetUsageViewSet(ListModelMixin, viewsets.GenericViewSet): > { > "asset": {asset_url}, > "asset_name": {string}, - > "nlp_usage_current_month": { + > "nlp_usage_current_period": { > "total_asr_seconds": {integer}, > "total_mt_characters": {integer}, > } @@ -36,7 +36,7 @@ class AssetUsageViewSet(ListModelMixin, viewsets.GenericViewSet): > "total_mt_characters": {integer}, > } > "storage_bytes": {integer}, - > "submission_count_current_month": {integer}, + > "submission_count_current_period": {integer}, > "submission_count_all_time": {integer}, > },{...} > ] diff --git a/kpi/views/v2/service_usage.py b/kpi/views/v2/service_usage.py index ef3a16b925..fab04450ef 100644 --- a/kpi/views/v2/service_usage.py +++ b/kpi/views/v2/service_usage.py @@ -28,23 +28,18 @@ class ServiceUsageViewSet(viewsets.GenericViewSet): > curl -X GET https://[kpi]/api/v2/service_usage/ > { > "total_nlp_usage": { - > "asr_seconds_current_month": {integer}, - > "asr_seconds_current_year": {integer}, + > "asr_seconds_current_period": {integer}, > "asr_seconds_all_time": {integer}, - > "mt_characters_current_month": {integer}, - > "mt_characters_current_year": {integer}, + > "mt_characters_current_period": {integer}, > "mt_characters_all_time": {integer}, > }, > "total_storage_bytes": {integer}, > "total_submission_count": { - > "current_month": {integer}, - > "current_year": {integer}, + > "current_period": {integer}, > "all_time": {integer}, > }, - > "current_month_start": {string (date), ISO format}, - > "current_year_start": {string (date), ISO format}, - > "current_month_end": {string (date), ISO format}, - > "current_year_end": {string (date), ISO format}, + > "current_period_start": {string (date), ISO format}, + > "current_period_end": {string (date), ISO format}, > } From de5ca42f3ec554b270ba6a0ee928a78adde13f71 Mon Sep 17 00:00:00 2001 From: James Kiger Date: Thu, 12 Dec 2024 12:53:42 -0500 Subject: [PATCH 2/4] Format python --- kobo/apps/organizations/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kobo/apps/organizations/utils.py b/kobo/apps/organizations/utils.py index beded90f30..76f779bd94 100644 --- a/kobo/apps/organizations/utils.py +++ b/kobo/apps/organizations/utils.py @@ -57,7 +57,7 @@ def get_billing_dates(organization: Union['Organization', None]): tzinfo=ZoneInfo('UTC') ) return period_start, period_end - + if billing_details.get('recurring_interval') == 'year': period_start = billing_details.get('current_period_start').replace( tzinfo=ZoneInfo('UTC') From 98bf7f77a68522ab50e6ed788f473fc434cd4ebc Mon Sep 17 00:00:00 2001 From: James Kiger Date: Thu, 12 Dec 2024 13:18:58 -0500 Subject: [PATCH 3/4] Remove print statement --- kpi/utils/usage_calculator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/kpi/utils/usage_calculator.py b/kpi/utils/usage_calculator.py index bed31bf624..3e80f72638 100644 --- a/kpi/utils/usage_calculator.py +++ b/kpi/utils/usage_calculator.py @@ -28,7 +28,6 @@ def __init__( self._user_id = self.organization.owner_user_object.pk now = timezone.now() - print(self.organization.__dict__) self.current_period_start, self.current_period_end = get_billing_dates( self.organization ) From a9ed28a586a44d42a25dc507955ac6fdf9fde588 Mon Sep 17 00:00:00 2001 From: James Kiger <68701146+jamesrkiger@users.noreply.github.com> Date: Mon, 16 Dec 2024 11:27:45 -0500 Subject: [PATCH 4/4] fix(billing): adjust frontend service usage billing period handling TASK-1328 (#5356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 📣 Summary Adjusts frontend types and handling of data service/asset usage data according to the changes in #5326. ### 📖 Description Previously, the ServiceUsageSerializer and AssetUsageSerializer returned usage information calculated according to a monthly billing cycle and a yearly billing cycle, regardless of what kind of billing cycle a user was actually subscribed to. So if a user was on a monthly plan, we made up a hypothetical yearly cycle and returned data for that in addition to the actual monthly cycle they are on. The frontend, in turn, selected the correct cycle information to display based on a separate request fetching the user’s subscription information. The hypothetical periods have been removed from the backend, which now returns only data for "current_period". This PR updates the frontend to handle this new data format. ### 👀 Preview steps 1. Create a new user on a Stripe-enabled instance 2. Navigate to usage page. Observe that user is on community plan with start date corresponding to account creation date. Displayed usage period should correspond to either first day of this month or last day of previous month (most likely the latter, though it currently depends on one’s timezone) and the last day of this month. 3. Purchase a monthly plan and sync djstripe Subscriptions, navigate to usage page. Observe that start date is still account creation date. Displayed usage period should now correspond to start date and one month after start date. 4. Cancel plan as the user, then cancel the plan via Stripe (or ask someone to do this), and sync subscriptions. Visit usage page and observe dates have not changed (because your community plan dates now correspond to date of cancellation and one month later). 5. Sign up for a yearly plan and sync subscriptions. Visit usage page and observe that start date remains the same, while displayed usage period ends a year from today. --- jsapp/js/account/usage/usage.api.ts | 24 ++++++------------- jsapp/js/account/usage/usage.component.tsx | 22 ++++------------- .../account/usage/usageProjectBreakdown.tsx | 10 +++----- jsapp/js/account/usage/useUsage.hook.ts | 21 +++++++--------- 4 files changed, 23 insertions(+), 54 deletions(-) diff --git a/jsapp/js/account/usage/usage.api.ts b/jsapp/js/account/usage/usage.api.ts index ca49a3b624..ea3014cc39 100644 --- a/jsapp/js/account/usage/usage.api.ts +++ b/jsapp/js/account/usage/usage.api.ts @@ -14,11 +14,7 @@ export interface AssetWithUsage { asset: string; uid: string; asset__name: string; - nlp_usage_current_month: { - total_nlp_asr_seconds: number; - total_nlp_mt_characters: number; - }; - nlp_usage_current_year: { + nlp_usage_current_period: { total_nlp_asr_seconds: number; total_nlp_mt_characters: number; }; @@ -27,28 +23,22 @@ export interface AssetWithUsage { total_nlp_mt_characters: number; }; storage_bytes: number; - submission_count_current_month: number; - submission_count_current_year: number; + submission_count_current_period: number; submission_count_all_time: number; deployment_status: string; } export interface UsageResponse { - current_month_start: string; - current_month_end: string; - current_year_start: string; - current_year_end: string; + current_period_start: string; + current_period_end: string; total_submission_count: { - current_month: number; - current_year: number; + current_period: number; all_time: number; }; total_storage_bytes: number; total_nlp_usage: { - asr_seconds_current_month: number; - mt_characters_current_month: number; - asr_seconds_current_year: number; - mt_characters_current_year: number; + asr_seconds_current_period: number; + mt_characters_current_period: number; asr_seconds_all_time: number; mt_characters_all_time: number; }; diff --git a/jsapp/js/account/usage/usage.component.tsx b/jsapp/js/account/usage/usage.component.tsx index 53cc27e158..f1bfd0cc5d 100644 --- a/jsapp/js/account/usage/usage.component.tsx +++ b/jsapp/js/account/usage/usage.component.tsx @@ -14,7 +14,6 @@ import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; import {ProductsContext} from '../useProducts.hook'; import {UsageContext} from 'js/account/usage/useUsage.hook'; import {OneTimeAddOnsContext} from '../useOneTimeAddonList.hook'; -import moment from 'moment'; import {YourPlan} from 'js/account/usage/yourPlan.component'; import cx from 'classnames'; import LimitNotifications from 'js/components/usageLimits/limitNotifications.component'; @@ -55,27 +54,14 @@ export default function Usage() { const location = useLocation(); const dateRange = useMemo(() => { - let startDate: string; - const endDate = usage.billingPeriodEnd - ? formatDate(usage.billingPeriodEnd) - : formatDate( - moment(usage.currentMonthStart).add(1, 'month').toISOString() - ); - switch (usage.trackingPeriod) { - case 'year': - startDate = formatDate(usage.currentYearStart); - break; - default: - startDate = formatDate(usage.currentMonthStart); - break; - } + const startDate = formatDate(usage.currentPeriodStart); + const endDate = formatDate(usage.currentPeriodEnd); return t('##start_date## to ##end_date##') .replace('##start_date##', startDate) .replace('##end_date##', endDate); }, [ - usage.currentYearStart, - usage.currentMonthStart, - usage.billingPeriodEnd, + usage.currentPeriodStart, + usage.currentPeriodEnd, usage.trackingPeriod, ]); diff --git a/jsapp/js/account/usage/usageProjectBreakdown.tsx b/jsapp/js/account/usage/usageProjectBreakdown.tsx index 6fe5387041..fcdbe442be 100644 --- a/jsapp/js/account/usage/usageProjectBreakdown.tsx +++ b/jsapp/js/account/usage/usageProjectBreakdown.tsx @@ -129,18 +129,14 @@ const ProjectBreakdown = () => { const renderProjectRow = (project: AssetWithUsage) => { const periodSubmissions = - project[ - `submission_count_current_${usage.trackingPeriod}` - ].toLocaleString(); + project.submission_count_current_period.toLocaleString(); const periodASRSeconds = convertSecondsToMinutes( - project[`nlp_usage_current_${usage.trackingPeriod}`].total_nlp_asr_seconds + project.nlp_usage_current_period.total_nlp_asr_seconds ).toLocaleString(); const periodMTCharacters = - project[ - `nlp_usage_current_${usage.trackingPeriod}` - ].total_nlp_mt_characters.toLocaleString(); + project.nlp_usage_current_period.total_nlp_mt_characters.toLocaleString(); return ( diff --git a/jsapp/js/account/usage/useUsage.hook.ts b/jsapp/js/account/usage/useUsage.hook.ts index 1166605524..ffb487e55b 100644 --- a/jsapp/js/account/usage/useUsage.hook.ts +++ b/jsapp/js/account/usage/useUsage.hook.ts @@ -10,9 +10,8 @@ export interface UsageState { submissions: number; transcriptionMinutes: number; translationChars: number; - currentMonthStart: string; - currentYearStart: string; - billingPeriodEnd: string | null; + currentPeriodStart: string; + currentPeriodEnd: string; trackingPeriod: RecurringInterval; lastUpdated?: String | null; } @@ -22,9 +21,8 @@ const INITIAL_USAGE_STATE: UsageState = Object.freeze({ submissions: 0, transcriptionMinutes: 0, translationChars: 0, - currentMonthStart: '', - currentYearStart: '', - billingPeriodEnd: null, + currentPeriodStart: '', + currentPeriodEnd: '', trackingPeriod: 'month', lastUpdated: '', }); @@ -49,15 +47,14 @@ const loadUsage = async ( } return { storage: usage.total_storage_bytes, - submissions: usage.total_submission_count[`current_${trackingPeriod}`], + submissions: usage.total_submission_count.current_period, transcriptionMinutes: convertSecondsToMinutes( - usage.total_nlp_usage[`asr_seconds_current_${trackingPeriod}`] + usage.total_nlp_usage.asr_seconds_current_period ), translationChars: - usage.total_nlp_usage[`mt_characters_current_${trackingPeriod}`], - currentMonthStart: usage.current_month_start, - currentYearStart: usage.current_year_start, - billingPeriodEnd: usage[`current_${trackingPeriod}_end`], + usage.total_nlp_usage.mt_characters_current_period, + currentPeriodStart: usage.current_period_start, + currentPeriodEnd: usage.current_period_end, trackingPeriod, lastUpdated, };