Skip to content

Commit

Permalink
feat(organizations): update is_mmo to identify mmo subscriptions TASK…
Browse files Browse the repository at this point in the history
…-1231 (#5289)

### 📣 Summary
Change the logic of the `is_mmo` property on the Organization model to
check Stripe product metadata for the `'mmo_enabled'` field.
  • Loading branch information
jamesrkiger authored Nov 28, 2024
1 parent 511f38e commit 38393e0
Show file tree
Hide file tree
Showing 6 changed files with 60 additions and 43 deletions.
12 changes: 10 additions & 2 deletions kobo/apps/organizations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions kobo/apps/organizations/tests/test_organizations_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
7 changes: 0 additions & 7 deletions kobo/apps/organizations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 33 additions & 16 deletions kobo/apps/stripe/tests/test_organization_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -433,31 +433,23 @@ 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
assert response.data['count'] == 1
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])
response = self.client.get(self.detail_url)
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):
Expand All @@ -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')
Expand Down Expand Up @@ -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
22 changes: 10 additions & 12 deletions kobo/apps/stripe/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)

Expand All @@ -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)
4 changes: 2 additions & 2 deletions kpi/tests/test_usage_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit 38393e0

Please sign in to comment.