diff --git a/kobo/apps/organizations/models.py b/kobo/apps/organizations/models.py index a51d2662af..fedff3754a 100644 --- a/kobo/apps/organizations/models.py +++ b/kobo/apps/organizations/models.py @@ -175,11 +175,19 @@ def is_mmo(self): This returns True if: - A superuser has enabled the override (`mmo_override`), or - - The organization has an active subscription. + - The organization has an active subscription to a plan with + mmo_enabled set to 'true' in the Stripe product metadata. If the override is enabled, it takes precedence over the subscription status """ - return self.mmo_override or bool(self.active_subscription_billing_details()) + if self.mmo_override: + return True + + if billing_details := self.active_subscription_billing_details(): + if product_metadata := billing_details.get('product_metadata'): + return product_metadata.get('mmo_enabled') == 'true' + + return False @cache_for_request def is_admin_only(self, user: 'User') -> bool: diff --git a/kobo/apps/organizations/tests/test_organizations_api.py b/kobo/apps/organizations/tests/test_organizations_api.py index a3fa635543..5648ca921e 100644 --- a/kobo/apps/organizations/tests/test_organizations_api.py +++ b/kobo/apps/organizations/tests/test_organizations_api.py @@ -34,6 +34,7 @@ class OrganizationApiTestCase(BaseTestCase): 'current_period_start': '2024-01-01', 'current_period_end': '2024-12-31' } + MMO_SUBSCRIPTION_DETAILS = {'product_metadata': {'mmo_enabled': 'true'}} def setUp(self): self.user = User.objects.get(username='someuser') @@ -121,13 +122,13 @@ def test_api_response_includes_is_mmo_with_mmo_override(self): @patch.object( Organization, 'active_subscription_billing_details', - return_value=DEFAULT_SUBSCRIPTION_DETAILS + return_value=MMO_SUBSCRIPTION_DETAILS, ) def test_api_response_includes_is_mmo_with_subscription( self, mock_active_subscription ): """ - Test that is_mmo is True when there is an active subscription. + Test that is_mmo is True when there is an active MMO subscription. """ self._insert_data(mmo_override=False) response = self.client.get(self.url_detail) @@ -154,14 +155,14 @@ def test_api_response_includes_is_mmo_with_no_override_and_no_subscription( @patch.object( Organization, 'active_subscription_billing_details', - return_value=DEFAULT_SUBSCRIPTION_DETAILS + return_value=MMO_SUBSCRIPTION_DETAILS, ) def test_api_response_includes_is_mmo_with_override_and_subscription( self, mock_active_subscription ): """ Test that is_mmo is True when both mmo_override and active - subscription is present. + MMO subscription is present. """ self._insert_data(mmo_override=True) response = self.client.get(self.url_detail) diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index 7fd0e59470..9c3d4634ee 100644 --- a/kobo/apps/organizations/views.py +++ b/kobo/apps/organizations/views.py @@ -9,10 +9,7 @@ OuterRef, ) from django.db.models.expressions import Exists -from django.utils.decorators import method_decorator from django.utils.http import http_date -from django.views.decorators.cache import cache_page -from django_dont_vary_on.decorators import only_vary_on from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.request import Request @@ -69,10 +66,6 @@ def get_queryset(self, *args, **kwargs): raise NotImplementedError -@method_decorator(cache_page(settings.ENDPOINT_CACHE_DURATION), name='service_usage') -# django uses the Vary header in its caching, and each middleware can potentially add more Vary headers -# we use this decorator to remove any Vary headers except 'origin' (we don't want to cache between different installs) -@method_decorator(only_vary_on('Origin'), name='service_usage') class OrganizationViewSet(viewsets.ModelViewSet): """ Organizations are groups of users with assigned permissions and configurations diff --git a/kobo/apps/stripe/tests/test_organization_usage.py b/kobo/apps/stripe/tests/test_organization_usage.py index 788122553e..a349361bff 100644 --- a/kobo/apps/stripe/tests/test_organization_usage.py +++ b/kobo/apps/stripe/tests/test_organization_usage.py @@ -18,7 +18,7 @@ from kobo.apps.organizations.models import Organization, OrganizationUser from kobo.apps.stripe.constants import USAGE_LIMIT_MAP from kobo.apps.stripe.tests.utils import ( - generate_enterprise_subscription, + generate_mmo_subscription, generate_plan_subscription, ) from kobo.apps.stripe.utils import get_organization_plan_limit @@ -102,7 +102,7 @@ def test_usage_for_plans_with_org_access(self): when viewing /service_usage/{organization_id}/ """ - generate_enterprise_subscription(self.organization) + generate_mmo_subscription(self.organization) # the user should see usage for everyone in their org response = self.client.get(self.detail_url) @@ -133,7 +133,7 @@ def test_endpoint_speed(self): # get the average request time for 10 hits to the endpoint single_user_time = timeit.timeit(lambda: self.client.get(self.detail_url), number=10) - generate_enterprise_subscription(self.organization) + generate_mmo_subscription(self.organization) # get the average request time for 10 hits to the endpoint multi_user_time = timeit.timeit(lambda: self.client.get(self.detail_url), number=10) @@ -146,7 +146,7 @@ def test_endpoint_is_cached(self): """ Test that multiple hits to the endpoint from the same origin are properly cached """ - generate_enterprise_subscription(self.organization) + 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 @@ -433,7 +433,7 @@ def test_user_not_member_of_organization(self): assert response.status_code == status.HTTP_400_BAD_REQUEST def test_successful_retrieval(self): - generate_enterprise_subscription(self.organization) + generate_mmo_subscription(self.organization) create_mock_assets([self.anotheruser]) response = self.client.get(self.detail_url) assert response.status_code == status.HTTP_200_OK @@ -441,8 +441,8 @@ def test_successful_retrieval(self): assert response.data['results'][0]['asset__name'] == 'test' assert response.data['results'][0]['deployment_status'] == 'deployed' - def test_aggregates_usage_for_enterprise_org(self): - generate_enterprise_subscription(self.organization) + def test_aggregates_usage_for_mmo(self): + generate_mmo_subscription(self.organization) self.organization.add_user(self.newuser) # create 2 additional assets, one per user create_mock_assets([self.anotheruser, self.newuser]) @@ -450,14 +450,6 @@ def test_aggregates_usage_for_enterprise_org(self): assert response.status_code == status.HTTP_200_OK assert response.data['count'] == 2 - def test_users_without_enterprise_see_only_their_usage(self): - generate_plan_subscription(self.organization) - self.organization.add_user(self.newuser) - create_mock_assets([self.anotheruser, self.newuser]) - response = self.client.get(self.detail_url) - assert response.status_code == status.HTTP_200_OK - assert response.data['count'] == 1 - @ddt class OrganizationsUtilsTestCase(BaseTestCase): @@ -473,7 +465,7 @@ def setUp(self): self.organization.add_user(self.anotheruser, is_admin=True) def test_get_plan_community_limit(self): - generate_enterprise_subscription(self.organization) + generate_mmo_subscription(self.organization) limit = get_organization_plan_limit(self.organization, 'seconds') assert limit == 2000 # TODO get the limits from the community plan, overrides limit = get_organization_plan_limit(self.organization, 'characters') @@ -504,3 +496,28 @@ def test_get_suscription_limit_unlimited(self, usage_type): generate_plan_subscription(self.organization, metadata=product_metadata) limit = get_organization_plan_limit(self.organization, usage_type) assert limit == float('inf') + + +@override_settings(STRIPE_ENABLED=True) +class OrganizationsModelIntegrationTestCase(BaseTestCase): + fixtures = ['test_data'] + + def setUp(self): + self.someuser = User.objects.get(username='someuser') + self.organization = self.someuser.organization + + def test_is_mmo_subscription_logic(self): + product_metadata = { + 'mmo_enabled': 'false', + } + subscription = generate_plan_subscription( + self.organization, metadata=product_metadata + ) + assert self.organization.is_mmo is False + subscription.status = 'canceled' + subscription.ended_at = timezone.now() + subscription.save() + + product_metadata['mmo_enabled'] = 'true' + generate_plan_subscription(self.organization, metadata=product_metadata) + assert self.organization.is_mmo is True diff --git a/kobo/apps/stripe/tests/utils.py b/kobo/apps/stripe/tests/utils.py index 55bb9e62af..e7dd249d86 100644 --- a/kobo/apps/stripe/tests/utils.py +++ b/kobo/apps/stripe/tests/utils.py @@ -2,7 +2,7 @@ from dateutil.relativedelta import relativedelta from django.utils import timezone -from djstripe.models import Customer, Product, SubscriptionItem, Subscription, Price +from djstripe.models import Customer, Price, Product, Subscription, SubscriptionItem from model_bakery import baker from kobo.apps.organizations.models import Organization @@ -17,7 +17,6 @@ def generate_plan_subscription( ) -> Subscription: """Create a subscription for a product with custom metadata""" created_date = timezone.now() - relativedelta(days=age_days) - price_id = 'price_sfmOFe33rfsfd36685657' if not customer: customer = baker.make(Customer, subscriber=organization, livemode=False) @@ -30,14 +29,12 @@ def generate_plan_subscription( product_metadata = {**product_metadata, **metadata} product = baker.make(Product, active=True, metadata=product_metadata) - if not (price := Price.objects.filter(id=price_id).first()): - price = baker.make( - Price, - active=True, - id=price_id, - recurring={'interval': interval}, - product=product, - ) + price = baker.make( + Price, + active=True, + recurring={'interval': interval}, + product=product, + ) period_offset = relativedelta(weeks=2) @@ -59,5 +56,6 @@ def generate_plan_subscription( ) -def generate_enterprise_subscription(organization: Organization, customer: Customer = None): - return generate_plan_subscription(organization, {'plan_type': 'enterprise'}, customer) +def generate_mmo_subscription(organization: Organization, customer: Customer = None): + product_metadata = {'mmo_enabled': 'true', 'plan_type': 'enterprise'} + return generate_plan_subscription(organization, product_metadata, customer) diff --git a/kpi/tests/test_usage_calculator.py b/kpi/tests/test_usage_calculator.py index e8a86fe7b8..82c3afc6f7 100644 --- a/kpi/tests/test_usage_calculator.py +++ b/kpi/tests/test_usage_calculator.py @@ -12,7 +12,7 @@ from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.organizations.models import Organization from kobo.apps.stripe.constants import USAGE_LIMIT_MAP -from kobo.apps.stripe.tests.utils import generate_enterprise_subscription +from kobo.apps.stripe.tests.utils import generate_mmo_subscription from kobo.apps.trackers.models import NLPUsageCounter from kpi.models import Asset from kpi.tests.base_test_case import BaseAssetTestCase @@ -201,7 +201,7 @@ def test_organization_setup(self): organization = baker.make(Organization, id='org_abcd1234', mmo_override=True) organization.add_user(user=self.anotheruser, is_admin=True) organization.add_user(user=self.someuser, is_admin=True) - generate_enterprise_subscription(organization) + generate_mmo_subscription(organization) calculator = ServiceUsageCalculator(self.someuser, organization) submission_counters = calculator.get_submission_counters()