Skip to content

Commit

Permalink
Domains: put a limit of 2 custom domains per project (#11629)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
humitos authored Oct 7, 2024
1 parent 9510a69 commit ed00aed
Show file tree
Hide file tree
Showing 10 changed files with 89 additions and 7 deletions.
25 changes: 25 additions & 0 deletions readthedocs/domains/validators.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions readthedocs/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion readthedocs/projects/tests/test_domain_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion readthedocs/proxito/tests/test_full.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion readthedocs/proxito/tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion readthedocs/proxito/tests/test_redirects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
52 changes: 52 additions & 0 deletions readthedocs/rtd_tests/tests/test_domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion readthedocs/rtd_tests/tests/test_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion readthedocs/rtd_tests/tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion readthedocs/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

0 comments on commit ed00aed

Please sign in to comment.