diff --git a/readthedocs/builds/tests/test_build_queryset.py b/readthedocs/builds/tests/test_build_queryset.py index 72d84e5cf3d..686701306f2 100644 --- a/readthedocs/builds/tests/test_build_queryset.py +++ b/readthedocs/builds/tests/test_build_queryset.py @@ -4,10 +4,16 @@ from readthedocs.builds.models import Build from readthedocs.organizations.models import Organization from readthedocs.projects.models import Project +from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS @pytest.mark.django_db class TestBuildQuerySet: + @pytest.fixture(autouse=True) + def setup_method(self, settings): + settings.RTD_DEFAULT_FEATURES = { + TYPE_CONCURRENT_BUILDS: 4, + } def test_concurrent_builds(self): project = fixture.get( diff --git a/readthedocs/core/resolver.py b/readthedocs/core/resolver.py index 24946148921..95f585017d7 100644 --- a/readthedocs/core/resolver.py +++ b/readthedocs/core/resolver.py @@ -7,6 +7,8 @@ from readthedocs.builds.constants import EXTERNAL 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 log = structlog.get_logger(__name__) @@ -404,7 +406,7 @@ def _use_subdomain(self): def _use_cname(self, project): """Test if to allow direct serving for project on CNAME.""" - return True + return PlanFeature.objects.has_feature(project, type=TYPE_CNAME) class Resolver(SettingsOverrideObject): diff --git a/readthedocs/organizations/tests/test_views.py b/readthedocs/organizations/tests/test_views.py index ef843fc12ca..6a10aa8fd96 100644 --- a/readthedocs/organizations/tests/test_views.py +++ b/readthedocs/organizations/tests/test_views.py @@ -16,6 +16,7 @@ from readthedocs.organizations.models import Organization, Team from readthedocs.projects.models import Project from readthedocs.rtd_tests.base import RequestFactoryTestMixin +from readthedocs.subscriptions.constants import TYPE_AUDIT_LOGS @override_settings(RTD_ALLOW_ORGANIZATIONS=True) @@ -147,7 +148,12 @@ def test_add_owner(self): self.assertNotIn(user_b, self.organization.owners.all()) -@override_settings(RTD_ALLOW_ORGANIZATIONS=True) +@override_settings( + RTD_ALLOW_ORGANIZATIONS=True, + RTD_DEFAULT_FEATURES={ + TYPE_AUDIT_LOGS: 90, + }, +) class OrganizationSecurityLogTests(TestCase): def setUp(self): diff --git a/readthedocs/organizations/views/private.py b/readthedocs/organizations/views/private.py index 012012e9583..29295bd70ef 100644 --- a/readthedocs/organizations/views/private.py +++ b/readthedocs/organizations/views/private.py @@ -13,7 +13,6 @@ from readthedocs.audit.models import AuditLog from readthedocs.core.history import UpdateChangeReasonPostView from readthedocs.core.mixins import PrivateViewMixin -from readthedocs.core.utils.extend import SettingsOverrideObject from readthedocs.invitations.models import Invitation from readthedocs.organizations.forms import ( OrganizationSignupForm, @@ -28,6 +27,8 @@ OrganizationView, ) from readthedocs.projects.utils import get_csv_file +from readthedocs.subscriptions.constants import TYPE_AUDIT_LOGS +from readthedocs.subscriptions.models import PlanFeature # Organization views @@ -183,12 +184,13 @@ def post(self, request, *args, **kwargs): return resp -class OrganizationSecurityLogBase(PrivateViewMixin, OrganizationMixin, ListView): +class OrganizationSecurityLog(PrivateViewMixin, OrganizationMixin, ListView): """Display security logs related to this organization.""" model = AuditLog template_name = 'organizations/security_log.html' + feature_type = TYPE_AUDIT_LOGS def get(self, request, *args, **kwargs): download_data = request.GET.get('download', False) @@ -234,10 +236,10 @@ 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_enabled(organization) - context['days_limit'] = self._get_retention_days_limit(organization) - context['filter'] = self.filter - context['AuditLog'] = AuditLog + context["enabled"] = self._is_feature_enabled(organization) + context["days_limit"] = self._get_retention_days_limit(organization) + context["filter"] = self.filter + context["AuditLog"] = AuditLog return context def _get_start_date(self): @@ -255,7 +257,7 @@ def _get_start_date(self): def _get_queryset(self): """Return the queryset without filters.""" organization = self.get_organization() - if not self._is_enabled(organization): + if not self._is_feature_enabled(organization): return AuditLog.objects.none() start_date = self._get_start_date() queryset = AuditLog.objects.filter( @@ -284,14 +286,15 @@ def get_queryset(self): ) return self.filter.qs - def _get_retention_days_limit(self, organization): # noqa - """From how many days we need to show data for this project?""" - return settings.RTD_AUDITLOGS_DEFAULT_RETENTION_DAYS - - def _is_enabled(self, organization): # noqa - """Should we show audit logs for this organization?""" - return True - + 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, + ) -class OrganizationSecurityLog(SettingsOverrideObject): - _default_class = OrganizationSecurityLogBase + def _is_feature_enabled(self, organization): + return PlanFeature.objects.has_feature( + organization, + type=self.feature_type, + ) diff --git a/readthedocs/projects/querysets.py b/readthedocs/projects/querysets.py index c7674b0ba61..c64e772a97a 100644 --- a/readthedocs/projects/querysets.py +++ b/readthedocs/projects/querysets.py @@ -1,5 +1,4 @@ """Project model QuerySet classes.""" -from django.conf import settings from django.db import models from django.db.models import Count, OuterRef, Prefetch, Q, Subquery @@ -96,6 +95,7 @@ def max_concurrent_builds(self, project): - project - organization + - plan - default setting :param project: project to be checked @@ -104,15 +104,21 @@ def max_concurrent_builds(self, project): :returns: number of max concurrent builds for the 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 return ( - project.max_concurrent_builds or - max_concurrent_organization or - settings.RTD_MAX_CONCURRENT_BUILDS + project.max_concurrent_builds + or max_concurrent_organization + or PlanFeature.objects.get_feature_value( + project, + type=TYPE_CONCURRENT_BUILDS, + ) ) def prefetch_latest_build(self): diff --git a/readthedocs/projects/tests/test_domain_views.py b/readthedocs/projects/tests/test_domain_views.py index fd3cd4faaa3..34bbcf8be48 100644 --- a/readthedocs/projects/tests/test_domain_views.py +++ b/readthedocs/projects/tests/test_domain_views.py @@ -5,6 +5,7 @@ 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 @@ -114,5 +115,5 @@ def setUp(self): self.feature = get( PlanFeature, plan=self.plan, - feature_type=PlanFeature.TYPE_CNAME, + feature_type=TYPE_CNAME, ) diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py index c39e201c1c5..fece00c1cc4 100644 --- a/readthedocs/projects/views/private.py +++ b/readthedocs/projects/views/private.py @@ -39,7 +39,6 @@ ) from readthedocs.core.history import UpdateChangeReasonPostView from readthedocs.core.mixins import ListViewWithForm, PrivateViewMixin -from readthedocs.core.utils.extend import SettingsOverrideObject from readthedocs.integrations.models import HttpExchange, Integration from readthedocs.invitations.models import Invitation from readthedocs.oauth.services import registry @@ -80,6 +79,12 @@ ProjectRelationListMixin, ) from readthedocs.search.models import SearchQuery +from readthedocs.subscriptions.constants import ( + TYPE_CNAME, + TYPE_PAGEVIEW_ANALYTICS, + TYPE_SEARCH_ANALYTICS, +) +from readthedocs.subscriptions.models import PlanFeature log = structlog.get_logger(__name__) @@ -752,6 +757,7 @@ class DomainMixin(ProjectAdminMixin, PrivateViewMixin): model = Domain form_class = DomainForm lookup_url_kwarg = 'domain_pk' + feature_type = TYPE_CNAME def get_success_url(self): return reverse('projects_domains', args=[self.get_project().slug]) @@ -763,11 +769,13 @@ def get_context_data(self, **kwargs): return context def _is_enabled(self, project): - """Should we allow custom domains for this project?""" - return True + return PlanFeature.objects.has_feature( + project, + type=self.feature_type, + ) -class DomainListBase(DomainMixin, ListViewWithForm): +class DomainList(DomainMixin, ListViewWithForm): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) @@ -782,12 +790,7 @@ def get_context_data(self, **kwargs): return ctx -class DomainList(SettingsOverrideObject): - - _default_class = DomainListBase - - -class DomainCreateBase(DomainMixin, CreateView): +class DomainCreate(DomainMixin, CreateView): def post(self, request, *args, **kwargs): project = self.get_project() @@ -806,12 +809,7 @@ def get_success_url(self): ) -class DomainCreate(SettingsOverrideObject): - - _default_class = DomainCreateBase - - -class DomainUpdateBase(DomainMixin, UpdateView): +class DomainUpdate(DomainMixin, UpdateView): def form_valid(self, form): response = super().form_valid(form) @@ -825,11 +823,6 @@ def post(self, request, *args, **kwargs): return HttpResponse('Action not allowed', status=401) -class DomainUpdate(SettingsOverrideObject): - - _default_class = DomainUpdateBase - - class DomainDelete(DomainMixin, DeleteView): pass @@ -1072,10 +1065,11 @@ class RegexAutomationRuleUpdate(RegexAutomationRuleMixin, UpdateView): pass -class SearchAnalyticsBase(ProjectAdminMixin, PrivateViewMixin, TemplateView): +class SearchAnalytics(ProjectAdminMixin, PrivateViewMixin, TemplateView): template_name = 'projects/projects_search_analytics.html' http_method_names = ['get'] + feature_type = TYPE_SEARCH_ANALYTICS def get(self, request, *args, **kwargs): download_data = request.GET.get('download', False) @@ -1159,21 +1153,24 @@ def _get_csv_data(self): def _get_retention_days_limit(self, project): """From how many days we need to show data for this project?""" - return settings.RTD_ANALYTICS_DEFAULT_RETENTION_DAYS + return PlanFeature.objects.get_feature_value( + project, + type=self.feature_type, + ) def _is_enabled(self, project): """Should we show search analytics for this project?""" - return True - - -class SearchAnalytics(SettingsOverrideObject): - _default_class = SearchAnalyticsBase + return PlanFeature.objects.has_feature( + project, + type=self.feature_type, + ) -class TrafficAnalyticsViewBase(ProjectAdminMixin, PrivateViewMixin, TemplateView): +class TrafficAnalyticsView(ProjectAdminMixin, PrivateViewMixin, TemplateView): template_name = 'projects/project_traffic_analytics.html' http_method_names = ['get'] + feature_type = TYPE_PAGEVIEW_ANALYTICS def get(self, request, *args, **kwargs): download_data = request.GET.get('download', False) @@ -1259,12 +1256,14 @@ def _get_csv_data(self): def _get_retention_days_limit(self, project): """From how many days we need to show data for this project?""" - return settings.RTD_ANALYTICS_DEFAULT_RETENTION_DAYS + return PlanFeature.objects.get_feature_value( + project, + type=self.feature_type, + ) def _is_enabled(self, project): """Should we show traffic analytics for this project?""" - return True - - -class TrafficAnalyticsView(SettingsOverrideObject): - _default_class = TrafficAnalyticsViewBase + return PlanFeature.objects.has_feature( + project, + type=self.feature_type, + ) diff --git a/readthedocs/proxito/tests/base.py b/readthedocs/proxito/tests/base.py index 906917ce123..298fce26b55 100644 --- a/readthedocs/proxito/tests/base.py +++ b/readthedocs/proxito/tests/base.py @@ -8,7 +8,7 @@ from django.test import TestCase from readthedocs.builds.constants import LATEST -from readthedocs.projects.constants import PUBLIC +from readthedocs.projects.constants import PUBLIC, SSL_STATUS_VALID from readthedocs.projects.models import Domain, Project from readthedocs.proxito.views import serve @@ -80,5 +80,17 @@ def setUp(self): self.project.add_subproject(self.subproject_alias, alias='this-is-an-alias') # These can be set to canonical as needed in specific tests - self.domain = fixture.get(Domain, project=self.project, domain='docs1.example.com', https=True) - self.domain2 = fixture.get(Domain, project=self.project, domain='docs2.example.com', https=True) + self.domain = fixture.get( + Domain, + project=self.project, + domain="docs1.example.com", + https=True, + ssl_status=SSL_STATUS_VALID, + ) + self.domain2 = fixture.get( + Domain, + project=self.project, + domain="docs2.example.com", + https=True, + ssl_status=SSL_STATUS_VALID, + ) diff --git a/readthedocs/proxito/tests/test_full.py b/readthedocs/proxito/tests/test_full.py index 75e1d2e8ee4..f501ab1ef9c 100644 --- a/readthedocs/proxito/tests/test_full.py +++ b/readthedocs/proxito/tests/test_full.py @@ -14,7 +14,6 @@ from readthedocs.audit.models import AuditLog from readthedocs.builds.constants import EXTERNAL, INTERNAL, LATEST from readthedocs.builds.models import Version -from readthedocs.organizations.models import Organization from readthedocs.projects import constants from readthedocs.projects.constants import ( DOWNLOADABLE_MEDIA_TYPES, @@ -33,7 +32,7 @@ BuildMediaFileSystemStorageTest, StaticFileSystemStorageTest, ) -from readthedocs.subscriptions.models import Plan, PlanFeature, Subscription +from readthedocs.subscriptions.constants import TYPE_CNAME from .base import BaseDocServing @@ -1514,6 +1513,9 @@ def test_404_download(self): ALLOW_PRIVATE_REPOS=True, PUBLIC_DOMAIN='dev.readthedocs.io', PUBLIC_DOMAIN_USES_HTTPS=True, + RTD_DEFAULT_FEATURES={ + TYPE_CNAME: 1, + }, ) # We are overriding the storage class instead of using RTD_BUILD_MEDIA_STORAGE, # since the setting is evaluated just once (first test to use the storage @@ -1741,30 +1743,6 @@ def test_cache_disable_on_rtd_header_resolved_project(self): self.assertEqual(resp.headers["CDN-Cache-Control"], "private") self.assertEqual(resp.headers["Cache-Tag"], "project,project:latest") - def test_cache_on_plan(self): - self.organization = get(Organization) - self.plan = get( - Plan, - published=True, - ) - self.subscription = get( - Subscription, - plan=self.plan, - organization=self.organization, - ) - self.feature = get( - PlanFeature, - plan=self.plan, - feature_type=PlanFeature.TYPE_CDN, - ) - - # Delete feature plan, so we aren't using that logic - Feature.objects.filter(feature_id=Feature.CDN_ENABLED).delete() - - # Add project to plan, so we're using that to enable CDN - self.organization.projects.add(self.project) - self._test_cache_control_header_project(expected_value="public") - class ProxitoV2TestCDNCache(TestCDNCache): # TODO: remove this class once the new implementation is the default. diff --git a/readthedocs/proxito/tests/test_middleware.py b/readthedocs/proxito/tests/test_middleware.py index 619598852d8..858344e7d34 100644 --- a/readthedocs/proxito/tests/test_middleware.py +++ b/readthedocs/proxito/tests/test_middleware.py @@ -16,10 +16,16 @@ from readthedocs.rtd_tests.base import RequestFactoryTestMixin from readthedocs.rtd_tests.storage import BuildMediaFileSystemStorageTest from readthedocs.rtd_tests.utils import create_user +from readthedocs.subscriptions.constants import TYPE_CNAME @pytest.mark.proxito -@override_settings(PUBLIC_DOMAIN='dev.readthedocs.io') +@override_settings( + PUBLIC_DOMAIN="dev.readthedocs.io", + RTD_DEFAULT_FEATURES={ + TYPE_CNAME: 1, + }, +) class MiddlewareTests(RequestFactoryTestMixin, TestCase): def setUp(self): diff --git a/readthedocs/proxito/tests/test_redirects.py b/readthedocs/proxito/tests/test_redirects.py index d670ad97346..2518228f998 100644 --- a/readthedocs/proxito/tests/test_redirects.py +++ b/readthedocs/proxito/tests/test_redirects.py @@ -4,6 +4,7 @@ from readthedocs.projects.models import Feature from readthedocs.proxito.constants import RedirectType +from readthedocs.subscriptions.constants import TYPE_CNAME from .base import BaseDocServing @@ -11,6 +12,9 @@ @override_settings( PUBLIC_DOMAIN='dev.readthedocs.io', PUBLIC_DOMAIN_USES_HTTPS=True, + RTD_DEFAULT_FEATURES={ + TYPE_CNAME: 1, + }, ) class RedirectTests(BaseDocServing): diff --git a/readthedocs/rtd_tests/tests/test_api.py b/readthedocs/rtd_tests/tests/test_api.py index 78cbc24983d..294a70f496f 100644 --- a/readthedocs/rtd_tests/tests/test_api.py +++ b/readthedocs/rtd_tests/tests/test_api.py @@ -60,6 +60,7 @@ Feature, Project, ) +from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS super_auth = base64.b64encode(b'super:test').decode('utf-8') eric_auth = base64.b64encode(b'eric:test').decode('utf-8') @@ -845,6 +846,11 @@ def test_init_api_project(self): {'RELEASE': 'prod'}, ) + @override_settings( + RTD_DEFAULT_FEATURES={ + TYPE_CONCURRENT_BUILDS: 4, + } + ) def test_concurrent_builds(self): expected = { 'limit_reached': False, diff --git a/readthedocs/rtd_tests/tests/test_footer.py b/readthedocs/rtd_tests/tests/test_footer.py index c6f823a8fdc..5ada66df22c 100644 --- a/readthedocs/rtd_tests/tests/test_footer.py +++ b/readthedocs/rtd_tests/tests/test_footer.py @@ -13,6 +13,7 @@ from readthedocs.core.middleware import ReadTheDocsSessionMiddleware from readthedocs.projects.constants import GITHUB_BRAND, GITLAB_BRAND, PUBLIC from readthedocs.projects.models import Project +from readthedocs.subscriptions.constants import TYPE_CNAME class BaseTestFooterHTML: @@ -444,7 +445,12 @@ def test_highest_version_without_tags(self): @pytest.mark.proxito -@override_settings(PUBLIC_DOMAIN='readthedocs.io') +@override_settings( + PUBLIC_DOMAIN="readthedocs.io", + RTD_DEFAULT_FEATURES={ + TYPE_CNAME: 1, + }, +) class TestFooterPerformance(TestCase): # The expected number of queries for generating the footer # This shouldn't increase unless we modify the footer API diff --git a/readthedocs/rtd_tests/tests/test_resolver.py b/readthedocs/rtd_tests/tests/test_resolver.py index 8e3e9d2f0d1..52a103971a4 100644 --- a/readthedocs/rtd_tests/tests/test_resolver.py +++ b/readthedocs/rtd_tests/tests/test_resolver.py @@ -7,9 +7,15 @@ from readthedocs.projects.constants import PRIVATE from readthedocs.projects.models import Domain, Project, ProjectRelationship from readthedocs.rtd_tests.utils import create_user +from readthedocs.subscriptions.constants import TYPE_CNAME -@override_settings(PUBLIC_DOMAIN='readthedocs.org') +@override_settings( + PUBLIC_DOMAIN="readthedocs.org", + RTD_DEFAULT_FEATURES={ + TYPE_CNAME: 1, + }, +) class ResolverBase(TestCase): def setUp(self): diff --git a/readthedocs/rtd_tests/tests/test_views.py b/readthedocs/rtd_tests/tests/test_views.py index 3de44848ab2..097e217a4db 100644 --- a/readthedocs/rtd_tests/tests/test_views.py +++ b/readthedocs/rtd_tests/tests/test_views.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from django.core.cache import cache -from django.test import TestCase +from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone from django_dynamic_fixture import get, new @@ -15,6 +15,7 @@ from readthedocs.projects.constants import PUBLIC from readthedocs.projects.forms import UpdateProjectForm from readthedocs.projects.models import Feature, Project +from readthedocs.subscriptions.constants import TYPE_SEARCH_ANALYTICS @mock.patch('readthedocs.projects.forms.trigger_build', mock.MagicMock()) @@ -333,6 +334,11 @@ def test_rebuild_invalid_specific_commit(self, mock): self.assertEqual(r.status_code, 302) +@override_settings( + RTD_DEFAULT_FEATURES={ + TYPE_SEARCH_ANALYTICS: 90, + } +) class TestSearchAnalyticsView(TestCase): """Tests for search analytics page.""" diff --git a/readthedocs/settings/base.py b/readthedocs/settings/base.py index 2d0dd75e0a5..415e261cb5a 100644 --- a/readthedocs/settings/base.py +++ b/readthedocs/settings/base.py @@ -157,6 +157,26 @@ def SESSION_COOKIE_SAMESITE(self): # Number of days an invitation is valid. RTD_INVITATIONS_EXPIRATION_DAYS = 15 + @property + def RTD_DEFAULT_FEATURES(self): + # Features listed here will be available to users that don't have a + # subscription or if their subscription doesn't include the feature. + # Depending on the feature type, the numeric value represents a + # number of days or limit of the feature. + from readthedocs.subscriptions import constants + return { + constants.TYPE_CNAME: 1, + constants.TYPE_EMBED_API: 1, + # Retention days for search analytics. + constants.TYPE_SEARCH_ANALYTICS: self.RTD_ANALYTICS_DEFAULT_RETENTION_DAYS, + # Retention days for page view analytics. + constants.TYPE_PAGEVIEW_ANALYTICS: self.RTD_ANALYTICS_DEFAULT_RETENTION_DAYS, + # Retention days for audit logs. + constants.TYPE_AUDIT_LOGS: self.RTD_AUDITLOGS_DEFAULT_RETENTION_DAYS, + # Max number of concurrent builds. + constants.TYPE_CONCURRENT_BUILDS: self.RTD_MAX_CONCURRENT_BUILDS, + } + # Database and API hitting settings DONT_HIT_API = False DONT_HIT_DB = True diff --git a/readthedocs/subscriptions/constants.py b/readthedocs/subscriptions/constants.py index ce2270e49bd..d6dd62782d4 100644 --- a/readthedocs/subscriptions/constants.py +++ b/readthedocs/subscriptions/constants.py @@ -1,4 +1,39 @@ """Constants for subscriptions.""" +from django.utils.translation import gettext_lazy as _ # Days after the subscription has ended to disable the organization DISABLE_AFTER_DAYS = 30 + +# Values from `value` that represent an unlimited value. +UNLIMITED_VALUES = [None, -1] + +TYPE_CNAME = "cname" +TYPE_CDN = "cdn" +TYPE_SSL = "ssl" +TYPE_SUPPORT = "support" + +TYPE_PRIVATE_DOCS = "private_docs" +TYPE_EMBED_API = "embed_api" +TYPE_SEARCH_ANALYTICS = "search_analytics" +TYPE_PAGEVIEW_ANALYTICS = "pageviews_analytics" +TYPE_CONCURRENT_BUILDS = "concurrent_builds" +TYPE_SSO = "sso" +TYPE_CUSTOM_URL = "urls" +TYPE_AUDIT_LOGS = "audit-logs" +TYPE_AUDIT_PAGEVIEWS = "audit-pageviews" + +FEATURE_TYPES = ( + (TYPE_CNAME, _("Custom domain")), + (TYPE_CDN, _("CDN public documentation")), + (TYPE_SSL, _("Custom SSL configuration")), + (TYPE_SUPPORT, _("Support SLA")), + (TYPE_PRIVATE_DOCS, _("Private documentation")), + (TYPE_EMBED_API, _("Embed content via API")), + (TYPE_SEARCH_ANALYTICS, _("Search analytics")), + (TYPE_PAGEVIEW_ANALYTICS, _("Pageview analytics")), + (TYPE_CONCURRENT_BUILDS, _("Concurrent builds")), + (TYPE_SSO, _("Single sign on (SSO) with Google")), + (TYPE_CUSTOM_URL, _("Custom URLs")), + (TYPE_AUDIT_LOGS, _("Audit logs")), + (TYPE_AUDIT_PAGEVIEWS, _("Record every page view")), +) diff --git a/readthedocs/subscriptions/managers.py b/readthedocs/subscriptions/managers.py index cf9c45afce3..3433fdef4e3 100644 --- a/readthedocs/subscriptions/managers.py +++ b/readthedocs/subscriptions/managers.py @@ -172,7 +172,7 @@ def get_feature(self, obj, type): Get feature `type` for `obj`. :param obj: An organization or project instance. - :param type: The type of the feature (PlanFeature.TYPE_*). + :param type: The type of the feature (readthedocs.subscriptions.constants.TYPE_*). :returns: A PlanFeature object or None. """ # Avoid circular imports. @@ -191,3 +191,32 @@ def get_feature(self, obj, type): plan__subscriptions__organization=organization, ) return feature.first() + + # pylint: disable=redefined-builtin + def get_feature_value(self, obj, type, default=0): + """ + Get the value of the given feature. + + Use this function instead of ``get_feature().value`` + when you need to respect the ``RTD_DEFAULT_FEATURES`` setting. + """ + # Hit the DB only if subscriptions are enabled. + if settings.RTD_ALLOW_ORGANIZATIONS: + feature = self.get_feature(obj, type) + if feature: + return feature.value + return settings.RTD_DEFAULT_FEATURES.get(type, default) + + # pylint: disable=redefined-builtin + def has_feature(self, obj, type): + """ + Get the value of the given feature. + + Use this function instead of ``bool(get_feature())`` + when you need to respect the ``RTD_DEFAULT_FEATURES`` setting. + """ + # Hit the DB only if subscriptions are enabled. + if settings.RTD_ALLOW_ORGANIZATIONS: + if self.get_feature(obj, type) is not None: + return True + return type in settings.RTD_DEFAULT_FEATURES diff --git a/readthedocs/subscriptions/models.py b/readthedocs/subscriptions/models.py index 9a2ef4afff4..ba426e9b9d1 100644 --- a/readthedocs/subscriptions/models.py +++ b/readthedocs/subscriptions/models.py @@ -10,10 +10,8 @@ from readthedocs.core.history import ExtraHistoricalRecords from readthedocs.core.utils import slugify from readthedocs.organizations.models import Organization -from readthedocs.subscriptions.managers import ( - PlanFeatureManager, - SubscriptionManager, -) +from readthedocs.subscriptions.constants import FEATURE_TYPES +from readthedocs.subscriptions.managers import PlanFeatureManager, SubscriptionManager class Plan(models.Model): @@ -87,41 +85,6 @@ class Meta: db_table = 'organizations_planfeature' unique_together = (('plan', 'feature_type'),) - # Constants - UNLIMITED_VALUES = [None, -1] - """Values from `value` that represent an unlimited value.""" - - TYPE_CNAME = 'cname' - TYPE_CDN = 'cdn' - TYPE_SSL = 'ssl' - TYPE_SUPPORT = 'support' - - TYPE_PRIVATE_DOCS = 'private_docs' - TYPE_EMBED_API = 'embed_api' - TYPE_SEARCH_ANALYTICS = 'search_analytics' - TYPE_PAGEVIEW_ANALYTICS = 'pageviews_analytics' - TYPE_CONCURRENT_BUILDS = 'concurrent_builds' - TYPE_SSO = 'sso' - TYPE_CUSTOM_URL = 'urls' - TYPE_AUDIT_LOGS = 'audit-logs' - TYPE_AUDIT_PAGEVIEWS = 'audit-pageviews' - - TYPES = ( - (TYPE_CNAME, _('Custom domain')), - (TYPE_CDN, _('CDN public documentation')), - (TYPE_SSL, _('Custom SSL configuration')), - (TYPE_SUPPORT, _('Support SLA')), - (TYPE_PRIVATE_DOCS, _('Private documentation')), - (TYPE_EMBED_API, _('Embed content via API')), - (TYPE_SEARCH_ANALYTICS, _('Search analytics')), - (TYPE_PAGEVIEW_ANALYTICS, _('Pageview analytics')), - (TYPE_CONCURRENT_BUILDS, _('Concurrent builds')), - (TYPE_SSO, _('Single sign on (SSO) with Google')), - (TYPE_CUSTOM_URL, _('Custom URLs')), - (TYPE_AUDIT_LOGS, _('Audit logs')), - (TYPE_AUDIT_PAGEVIEWS, _('Record every page view')), - ) - # Auto fields pub_date = models.DateTimeField(_('Publication date'), auto_now_add=True) modified_date = models.DateTimeField(_('Modified date'), auto_now=True) @@ -131,8 +94,8 @@ class Meta: related_name='features', on_delete=models.CASCADE, ) - feature_type = models.CharField(_('Type'), max_length=32, choices=TYPES) - value = models.IntegerField(_('Numeric value'), null=True, blank=True) + feature_type = models.CharField(_("Type"), max_length=32, choices=FEATURE_TYPES) + value = models.IntegerField(_("Numeric value"), null=True, blank=True) description = models.CharField( _('Description'), max_length=255,