From ed00aedaaab3696c819307f39f06ba1d93083cbd Mon Sep 17 00:00:00 2001 From: Manuel Kaufmann Date: Mon, 7 Oct 2024 21:25:12 +0200 Subject: [PATCH] Domains: put a limit of 2 custom domains per project (#11629) * Domains: put a limit of 2 custom domains per project Related #1808 * Domains: add tests for limit amount of domains * Add missing import * Test: define 2 domains as limit for all the tests --- readthedocs/domains/validators.py | 25 +++++++++ readthedocs/projects/models.py | 4 ++ .../projects/tests/test_domain_views.py | 2 +- readthedocs/proxito/tests/test_full.py | 2 +- readthedocs/proxito/tests/test_middleware.py | 2 +- readthedocs/proxito/tests/test_redirects.py | 2 +- readthedocs/rtd_tests/tests/test_domains.py | 52 +++++++++++++++++++ readthedocs/rtd_tests/tests/test_footer.py | 2 +- readthedocs/rtd_tests/tests/test_resolver.py | 2 +- readthedocs/settings/base.py | 3 +- 10 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 readthedocs/domains/validators.py diff --git a/readthedocs/domains/validators.py b/readthedocs/domains/validators.py new file mode 100644 index 00000000000..8820a1459e8 --- /dev/null +++ b/readthedocs/domains/validators.py @@ -0,0 +1,25 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from readthedocs.subscriptions.constants import TYPE_CNAME +from readthedocs.subscriptions.products import get_feature + + +def check_domains_limit(project, error_class=ValidationError): + """Check if the project has reached the limit on the number of domains.""" + feature = get_feature(project, TYPE_CNAME) + if feature.unlimited: + return + + if project.domains.count() >= feature.value: + msg = _( + f"This project has reached the limit of {feature.value} domains." + " Consider removing unused domains." + ) + if settings.ALLOW_PRIVATE_REPOS: + msg = _( + f"Your organization has reached the limit of {feature.value} domains." + " Consider removing unused domains or upgrading your plan." + ) + raise error_class(msg) diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index b3f9a9b9aba..e0fc30ec291 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -36,6 +36,7 @@ from readthedocs.core.utils import extract_valid_attributes_for_model, slugify from readthedocs.core.utils.url import unsafe_join_url_path from readthedocs.domains.querysets import DomainQueryset +from readthedocs.domains.validators import check_domains_limit from readthedocs.notifications.models import Notification as NewNotification from readthedocs.projects import constants from readthedocs.projects.exceptions import ProjectConfigurationError @@ -1809,6 +1810,9 @@ def restart_validation_process(self): self.validation_process_start = timezone.now() self.save() + def clean(self): + check_domains_limit(self.project) + def save(self, *args, **kwargs): parsed = urlparse(self.domain) if parsed.scheme or parsed.netloc: diff --git a/readthedocs/projects/tests/test_domain_views.py b/readthedocs/projects/tests/test_domain_views.py index a98bfb9c1b1..10ed1de63d4 100644 --- a/readthedocs/projects/tests/test_domain_views.py +++ b/readthedocs/projects/tests/test_domain_views.py @@ -12,7 +12,7 @@ @override_settings( RTD_ALLOW_ORGANIZATIONS=False, - RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME).to_item()]), + RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME, value=2).to_item()]), ) class TestDomainViews(TestCase): def setUp(self): diff --git a/readthedocs/proxito/tests/test_full.py b/readthedocs/proxito/tests/test_full.py index 167e4c60324..d78a66b2087 100644 --- a/readthedocs/proxito/tests/test_full.py +++ b/readthedocs/proxito/tests/test_full.py @@ -1725,7 +1725,7 @@ def test_404_download(self): ALLOW_PRIVATE_REPOS=True, PUBLIC_DOMAIN="dev.readthedocs.io", PUBLIC_DOMAIN_USES_HTTPS=True, - RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME).to_item()]), + RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME, value=2).to_item()]), ) # 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 diff --git a/readthedocs/proxito/tests/test_middleware.py b/readthedocs/proxito/tests/test_middleware.py index 804ba84beb2..6866c4c3622 100644 --- a/readthedocs/proxito/tests/test_middleware.py +++ b/readthedocs/proxito/tests/test_middleware.py @@ -21,7 +21,7 @@ @pytest.mark.proxito @override_settings( PUBLIC_DOMAIN="dev.readthedocs.io", - RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME).to_item()]), + RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME, value=2).to_item()]), ) class MiddlewareTests(RequestFactoryTestMixin, TestCase): def setUp(self): diff --git a/readthedocs/proxito/tests/test_redirects.py b/readthedocs/proxito/tests/test_redirects.py index 21da38c0c3f..1d842fcdde2 100644 --- a/readthedocs/proxito/tests/test_redirects.py +++ b/readthedocs/proxito/tests/test_redirects.py @@ -15,7 +15,7 @@ PUBLIC_DOMAIN="dev.readthedocs.io", RTD_EXTERNAL_VERSION_DOMAIN="dev.readthedocs.build", PUBLIC_DOMAIN_USES_HTTPS=True, - RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME).to_item()]), + RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME, value=2).to_item()]), ) class RedirectTests(BaseDocServing): def test_root_url_no_slash(self): diff --git a/readthedocs/rtd_tests/tests/test_domains.py b/readthedocs/rtd_tests/tests/test_domains.py index 2af9ab831f7..aa1077fdd56 100644 --- a/readthedocs/rtd_tests/tests/test_domains.py +++ b/readthedocs/rtd_tests/tests/test_domains.py @@ -4,6 +4,8 @@ from readthedocs.projects.forms import DomainForm from readthedocs.projects.models import Domain, Project +from readthedocs.subscriptions.constants import TYPE_CNAME +from readthedocs.subscriptions.products import RTDProductFeature, get_feature class ModelTests(TestCase): @@ -205,3 +207,53 @@ def test_dont_allow_changin_https_to_http(self): self.assertTrue(form.is_valid()) domain = form.save() self.assertTrue(domain.https) + + @override_settings( + RTD_DEFAULT_FEATURES=dict( + [RTDProductFeature(type=TYPE_CNAME, value=2).to_item()] + ), + ) + def test_domains_limit(self): + feature = get_feature(self.project, TYPE_CNAME) + form = DomainForm( + { + "domain": "docs.user.example.com", + "canonical": True, + }, + project=self.project, + ) + self.assertTrue(form.is_valid()) + form.save() + self.assertEqual(self.project.domains.all().count(), 1) + + form = DomainForm( + { + "domain": "docs.dev.example.com", + "canonical": False, + }, + project=self.project, + ) + self.assertTrue(form.is_valid()) + form.save() + self.assertEqual(self.project.domains.all().count(), 2) + + # Creating the third (3) domain should fail the validation form + form = DomainForm( + { + "domain": "docs.customer.example.com", + "canonical": False, + }, + project=self.project, + ) + self.assertFalse(form.is_valid()) + + msg = ( + f"This project has reached the limit of {feature.value} domains. " + "Consider removing unused domains." + ) + if settings.RTD_ALLOW_ORGANIZATIONS: + msg = ( + f"Your organization has reached the limit of {feature.value} domains. " + "Consider removing unused domains or upgrading your plan." + ) + self.assertEqual(form.errors["__all__"][0], msg) diff --git a/readthedocs/rtd_tests/tests/test_footer.py b/readthedocs/rtd_tests/tests/test_footer.py index 13c0bfba97a..86a938105f1 100644 --- a/readthedocs/rtd_tests/tests/test_footer.py +++ b/readthedocs/rtd_tests/tests/test_footer.py @@ -479,7 +479,7 @@ def test_private_highest_version(self): @pytest.mark.proxito @override_settings( PUBLIC_DOMAIN="readthedocs.io", - RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME).to_item()]), + RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME, value=2).to_item()]), ) class TestFooterPerformance(TestCase): # The expected number of queries for generating the footer diff --git a/readthedocs/rtd_tests/tests/test_resolver.py b/readthedocs/rtd_tests/tests/test_resolver.py index 875ddd47734..87e9cb97cce 100644 --- a/readthedocs/rtd_tests/tests/test_resolver.py +++ b/readthedocs/rtd_tests/tests/test_resolver.py @@ -20,7 +20,7 @@ @override_settings( PUBLIC_DOMAIN="readthedocs.org", - RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME).to_item()]), + RTD_DEFAULT_FEATURES=dict([RTDProductFeature(type=TYPE_CNAME, value=2).to_item()]), ) class ResolverBase(TestCase): def setUp(self): diff --git a/readthedocs/settings/base.py b/readthedocs/settings/base.py index e76ff94be3e..199da500bad 100644 --- a/readthedocs/settings/base.py +++ b/readthedocs/settings/base.py @@ -170,7 +170,8 @@ def RTD_DEFAULT_FEATURES(self): return dict( ( - RTDProductFeature(type=constants.TYPE_CNAME).to_item(), + # Max number of domains allowed per project. + RTDProductFeature(type=constants.TYPE_CNAME, value=2).to_item(), RTDProductFeature(type=constants.TYPE_EMBED_API).to_item(), # Retention days for search analytics. RTDProductFeature(