Skip to content

Subscriptions: use djstripe for products/features #10238

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 43 commits into from
Aug 10, 2023
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
5570a73
Subscriptions: use djstripe for products/features
stsewd Apr 12, 2023
703cd65
Fix tests
stsewd Apr 12, 2023
efde180
Updates
stsewd Apr 12, 2023
d207565
Linter
stsewd Apr 12, 2023
b7f9c85
More updates
stsewd Apr 12, 2023
4e11f6f
Linter
stsewd Apr 13, 2023
a1cb32e
Merge branch 'main' into use-djstripe-for-features
stsewd Apr 13, 2023
27f9003
Default
stsewd Apr 13, 2023
2aa04f8
Default listed to False
stsewd Apr 13, 2023
356f18d
Fix tests
stsewd Apr 13, 2023
3c65b4b
Black
stsewd Apr 13, 2023
2fc1cbb
Merge branch 'main' into use-djstripe-for-features
stsewd Apr 18, 2023
e432063
Fixes
stsewd Apr 18, 2023
6462892
Fix
stsewd Apr 18, 2023
cd25bba
Merge branch 'main' into use-djstripe-for-features
stsewd May 10, 2023
9864e44
Merge branch 'main' into use-djstripe-for-features
stsewd Jul 25, 2023
aeadfb6
Migrate new tests
stsewd Jul 25, 2023
90f6928
Fix linter
stsewd Jul 26, 2023
8409136
Migrate more features
stsewd Jul 26, 2023
91284b9
Small fixes
stsewd Jul 26, 2023
52a36c5
Document local testing
stsewd Jul 26, 2023
3d9ea19
Include in index
stsewd Jul 26, 2023
29faab5
Filter by active prices
stsewd Jul 26, 2023
f9b060c
Better description for page views logs
stsewd Jul 27, 2023
2399515
Support for multiple products
stsewd Jul 31, 2023
a8950d3
Migration
stsewd Jul 31, 2023
3084035
format
stsewd Jul 31, 2023
45a1574
More docs
stsewd Jul 31, 2023
8589904
Merge branch 'main' into use-djstripe-for-features
stsewd Jul 31, 2023
9f6e3bf
Use new logic to get subscription
stsewd Jul 31, 2023
03d5139
Fix
stsewd Aug 1, 2023
df0a7d5
Invert
stsewd Aug 1, 2023
dd42fd9
Linter
stsewd Aug 1, 2023
b89f7aa
Fix test on .com
stsewd Aug 1, 2023
f1486f6
Quantity
stsewd Aug 1, 2023
6c38514
Tests
stsewd Aug 1, 2023
95eb6f1
More tests
stsewd Aug 1, 2023
19dd947
Fix test on .com
stsewd Aug 1, 2023
87d1cd9
Merge branch 'main' into use-djstripe-for-features
stsewd Aug 3, 2023
62f29d3
Avoid nesting
stsewd Aug 3, 2023
c0ac743
Merge branch 'main' into use-djstripe-for-features
stsewd Aug 8, 2023
aa92198
Merge branch 'main' into use-djstripe-for-features
stsewd Aug 10, 2023
83e26d1
Update from review
stsewd Aug 10, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/dev/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ or taking the open source Read the Docs codebase for your own custom installatio
i18n
server-side-search
search-integration
subscriptions
settings
tests
32 changes: 32 additions & 0 deletions docs/dev/subscriptions.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
Subscriptions
=============

Subscriptions are available on |com_brand|,
we make use of Stripe to handle the payments and subscriptions.

Local testing
-------------

To test subscriptions locally, you need to have access to the Stripe account,
and define the following settings with the keys from Stripe test mode:

- ``STRIPE_SECRET``: https://dashboard.stripe.com/test/apikeys
- ``STRIPE_TEST_SECRET_KEY``: https://dashboard.stripe.com/test/apikeys
- ``DJSTRIPE_WEBHOOK_SECRET``: https://dashboard.stripe.com/test/webhooks

To test the webhook locally, you need to run your local instance with ngrok, for example:

.. code-block:: bash

ngrok http 80
inv docker.up --http-domain xxx.ngrok.io

If this is your first time setting up subscriptions, you will to re-sync djstripe with Stripe:

.. code-block:: bash

inv docker.manage djstripe_sync_models

The subscription settings (``RTD_PRODUCTS``) already mapped to match the Stripe prices from the test mode.
To subscribe to any plan, you can use any `test card from Stripe <https://stripe.com/docs/testing>`__,
for example: ``4242 4242 4242 4242`` (use any future date and any value for the other fields).
4 changes: 2 additions & 2 deletions readthedocs/api/v3/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from readthedocs.core.utils.extend import SettingsOverrideObject
from readthedocs.subscriptions.constants import TYPE_EMBED_API
from readthedocs.subscriptions.models import PlanFeature
from readthedocs.subscriptions.products import get_feature


class HasEmbedAPIAccess(BasePermission):
Expand All @@ -24,7 +24,7 @@ class HasEmbedAPIAccess(BasePermission):
def has_permission(self, request, view):
project = view._get_project()
# The project is None when the is requesting a section from an external site.
if project and not PlanFeature.objects.has_feature(project, TYPE_EMBED_API):
if project and not get_feature(project, feature_type=TYPE_EMBED_API):
return False
return True

Expand Down
7 changes: 4 additions & 3 deletions readthedocs/api/v3/tests/test_builds.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
from django.urls import reverse

from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS
from readthedocs.subscriptions.products import RTDProductFeature

from .mixins import APIEndpointMixin


@override_settings(
RTD_ALLOW_ORGANIZATIONS=False,
ALLOW_PRIVATE_REPOS=False,
RTD_DEFAULT_FEATURES={
TYPE_CONCURRENT_BUILDS: 4,
},
RTD_DEFAULT_FEATURES=dict(
[RTDProductFeature(TYPE_CONCURRENT_BUILDS, value=4).to_item()]
),
)
@mock.patch('readthedocs.projects.tasks.builds.update_docs_task', mock.MagicMock())
class BuildsEndpointTests(APIEndpointMixin):
Expand Down
7 changes: 4 additions & 3 deletions readthedocs/builds/tests/test_build_queryset.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
from readthedocs.organizations.models import Organization
from readthedocs.projects.models import Project
from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS
from readthedocs.subscriptions.products import RTDProductFeature


@pytest.mark.django_db
class TestBuildQuerySet:
@pytest.fixture(autouse=True)
def setup_method(self, settings):
settings.RTD_DEFAULT_FEATURES = {
TYPE_CONCURRENT_BUILDS: 4,
}
settings.RTD_DEFAULT_FEATURES = dict(
[RTDProductFeature(type=TYPE_CONCURRENT_BUILDS, value=4).to_item()]
)

def test_concurrent_builds(self):
project = fixture.get(
Expand Down
4 changes: 2 additions & 2 deletions readthedocs/core/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from readthedocs.core.utils.extend import SettingsOverrideObject
from readthedocs.core.utils.url import unsafe_join_url_path
from readthedocs.subscriptions.constants import TYPE_CNAME
from readthedocs.subscriptions.models import PlanFeature
from readthedocs.subscriptions.products import get_feature

log = structlog.get_logger(__name__)

Expand Down Expand Up @@ -416,7 +416,7 @@ def _use_subdomain(self):

def _use_cname(self, project):
"""Test if to allow direct serving for project on CNAME."""
return PlanFeature.objects.has_feature(project, type=TYPE_CNAME)
return bool(get_feature(project, feature_type=TYPE_CNAME))


class Resolver(SettingsOverrideObject):
Expand Down
9 changes: 5 additions & 4 deletions readthedocs/embed/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from readthedocs.projects.constants import MKDOCS, PUBLIC
from readthedocs.projects.models import Project
from readthedocs.subscriptions.constants import TYPE_EMBED_API
from readthedocs.subscriptions.products import RTDProductFeature

data_path = Path(__file__).parent.resolve() / 'data'

Expand All @@ -34,10 +35,10 @@ def setup_method(self, settings):
self.version.save()

settings.USE_SUBDOMAIN = True
settings.PUBLIC_DOMAIN = 'readthedocs.io'
settings.RTD_DEFAULT_FEATURES = {
TYPE_EMBED_API: 1,
}
settings.PUBLIC_DOMAIN = "readthedocs.io"
settings.RTD_DEFAULT_FEATURES = dict(
[RTDProductFeature(TYPE_EMBED_API).to_item()]
)

def get(self, client, *args, **kwargs):
"""Wrapper around ``client.get`` to be overridden in the proxied api tests."""
Expand Down
5 changes: 2 additions & 3 deletions readthedocs/embed/v3/tests/test_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,14 @@
from readthedocs.projects.constants import PRIVATE, PUBLIC
from readthedocs.projects.models import Project
from readthedocs.subscriptions.constants import TYPE_EMBED_API
from readthedocs.subscriptions.products import RTDProductFeature


@override_settings(
USE_SUBDOMAIN=True,
PUBLIC_DOMAIN="readthedocs.io",
RTD_ALLOW_ORGAZATIONS=False,
RTD_DEFAULT_FEATURES={
TYPE_EMBED_API: 1,
},
RTD_DEFAULT_FEATURES=dict([RTDProductFeature(TYPE_EMBED_API).to_item()]),
)
@mock.patch("readthedocs.embed.v3.views.build_media_storage")
class TestEmbedAPIV3Access(TestCase):
Expand Down
7 changes: 4 additions & 3 deletions readthedocs/embed/v3/tests/test_internal_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from readthedocs.projects.models import Project
from readthedocs.subscriptions.constants import TYPE_EMBED_API
from readthedocs.subscriptions.products import RTDProductFeature

from .utils import srcdir

Expand All @@ -24,9 +25,9 @@ def setup_method(self, settings):
settings.USE_SUBDOMAIN = True
settings.PUBLIC_DOMAIN = 'readthedocs.io'
settings.RTD_EMBED_API_EXTERNAL_DOMAINS = []
settings.RTD_DEFAULT_FEATURES = {
TYPE_EMBED_API: 1,
}
settings.RTD_DEFAULT_FEATURES = dict(
[RTDProductFeature(TYPE_EMBED_API).to_item()]
)

self.api_url = reverse('embed_api_v3')

Expand Down
19 changes: 7 additions & 12 deletions readthedocs/organizations/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from readthedocs.projects.models import Project
from readthedocs.rtd_tests.base import RequestFactoryTestMixin
from readthedocs.subscriptions.constants import TYPE_AUDIT_LOGS
from readthedocs.subscriptions.products import RTDProductFeature


@override_settings(RTD_ALLOW_ORGANIZATIONS=True)
Expand Down Expand Up @@ -150,9 +151,9 @@ def test_add_owner(self):

@override_settings(
RTD_ALLOW_ORGANIZATIONS=True,
RTD_DEFAULT_FEATURES={
TYPE_AUDIT_LOGS: 90,
},
RTD_DEFAULT_FEATURES=dict(
[RTDProductFeature(type=TYPE_AUDIT_LOGS, value=90).to_item()]
),
)
class OrganizationSecurityLogTests(TestCase):

Expand Down Expand Up @@ -380,9 +381,7 @@ def test_organization_signup(self):

@override_settings(
RTD_ALLOW_ORGANIZATIONS=True,
RTD_DEFAULT_FEATURES={
TYPE_AUDIT_LOGS: 90,
},
RTD_DEFAULT_FEATURES=dict([RTDProductFeature(TYPE_AUDIT_LOGS, value=90).to_item()]),
)
class OrganizationUnspecifiedChooser(TestCase):
def setUp(self):
Expand Down Expand Up @@ -428,9 +427,7 @@ def test_choose_organization_edit(self):

@override_settings(
RTD_ALLOW_ORGANIZATIONS=True,
RTD_DEFAULT_FEATURES={
TYPE_AUDIT_LOGS: 90,
},
RTD_DEFAULT_FEATURES=dict([RTDProductFeature(TYPE_AUDIT_LOGS, value=90).to_item()]),
)
class OrganizationUnspecifiedSingleOrganizationRedirect(TestCase):
def setUp(self):
Expand All @@ -456,9 +453,7 @@ def test_unspecified_slug_redirects_to_organization_edit(self):

@override_settings(
RTD_ALLOW_ORGANIZATIONS=True,
RTD_DEFAULT_FEATURES={
TYPE_AUDIT_LOGS: 90,
},
RTD_DEFAULT_FEATURES=dict([RTDProductFeature(TYPE_AUDIT_LOGS, value=90).to_item()]),
)
class OrganizationUnspecifiedNoOrganizationRedirect(TestCase):
def setUp(self):
Expand Down
31 changes: 10 additions & 21 deletions readthedocs/organizations/views/private.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
)
from readthedocs.projects.utils import get_csv_file
from readthedocs.subscriptions.constants import TYPE_AUDIT_LOGS
from readthedocs.subscriptions.models import PlanFeature
from readthedocs.subscriptions.products import get_feature


# Organization views
Expand Down Expand Up @@ -265,8 +265,9 @@ def _get_csv_data(self):
def get_context_data(self, **kwargs):
organization = self.get_organization()
context = super().get_context_data(**kwargs)
context["enabled"] = self._is_feature_enabled(organization)
context["days_limit"] = self._get_retention_days_limit(organization)
feature = self._get_feature(organization)
context["enabled"] = bool(feature)
context["days_limit"] = feature.value if feature else 0
context["filter"] = self.filter
context["AuditLog"] = AuditLog
return context
Expand All @@ -275,18 +276,17 @@ def _get_start_date(self):
"""Get the date to show logs from."""
organization = self.get_organization()
creation_date = organization.pub_date.date()
retention_limit = self._get_retention_days_limit(organization)
if retention_limit in [None, -1]:
# Unlimited.
feature = self._get_feature(organization)
if feature.unlimited:
return creation_date
start_date = timezone.now().date() - timezone.timedelta(days=retention_limit)
start_date = timezone.now().date() - timezone.timedelta(days=feature.value)
# The max we can go back is to the creation of the organization.
return max(start_date, creation_date)

def _get_queryset(self):
"""Return the queryset without filters."""
organization = self.get_organization()
if not self._is_feature_enabled(organization):
if not self._get_feature(organization):
return AuditLog.objects.none()
start_date = self._get_start_date()
queryset = AuditLog.objects.filter(
Expand All @@ -308,22 +308,11 @@ def get_queryset(self):
queryset = self._get_queryset()
# Set filter on self, so we can use it in the context.
# Without executing it twice.
# pylint: disable=attribute-defined-outside-init
self.filter = OrganizationSecurityLogFilter(
self.request.GET,
queryset=queryset,
)
return self.filter.qs

def _get_retention_days_limit(self, organization):
"""From how many days we need to show data for this organization?"""
return PlanFeature.objects.get_feature_value(
organization,
type=self.feature_type,
)

def _is_feature_enabled(self, organization):
return PlanFeature.objects.has_feature(
organization,
type=self.feature_type,
)
def _get_feature(self, organization):
return get_feature(organization, self.feature_type)
9 changes: 4 additions & 5 deletions readthedocs/projects/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from readthedocs.core.permissions import AdminPermission
from readthedocs.core.utils.extend import SettingsOverrideObject
from readthedocs.projects import constants
from readthedocs.subscriptions.products import get_feature


class ProjectQuerySetBase(models.QuerySet):
Expand Down Expand Up @@ -105,20 +106,18 @@ def max_concurrent_builds(self, project):
:rtype: int
"""
from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS
from readthedocs.subscriptions.models import PlanFeature

max_concurrent_organization = None
organization = project.organizations.first()
if organization:
max_concurrent_organization = organization.max_concurrent_builds

feature = get_feature(project, feature_type=TYPE_CONCURRENT_BUILDS)
feature_value = feature.value if feature else 1
return (
project.max_concurrent_builds
or max_concurrent_organization
or PlanFeature.objects.get_feature_value(
project,
type=TYPE_CONCURRENT_BUILDS,
)
or feature_value
)

def prefetch_latest_build(self):
Expand Down
21 changes: 5 additions & 16 deletions readthedocs/projects/tests/test_domain_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
from readthedocs.organizations.models import Organization
from readthedocs.projects.models import Domain, Project
from readthedocs.subscriptions.constants import TYPE_CNAME
from readthedocs.subscriptions.models import Plan, PlanFeature, Subscription
from readthedocs.subscriptions.products import RTDProductFeature


@override_settings(RTD_ALLOW_ORGANIZATIONS=False)
@override_settings(
RTD_ALLOW_ORGANIZATIONS=False,
RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME).to_item()]),
)
class TestDomainViews(TestCase):
def setUp(self):
self.user = get(User, username="user")
Expand Down Expand Up @@ -103,17 +106,3 @@ def setUp(self):
self.org = get(
Organization, owners=[self.user], projects=[self.project, self.subproject]
)
self.plan = get(
Plan,
published=True,
)
self.subscription = get(
Subscription,
plan=self.plan,
organization=self.org,
)
self.feature = get(
PlanFeature,
plan=self.plan,
feature_type=TYPE_CNAME,
)
Loading