Skip to content

Commit 134f5ca

Browse files
stsewdagjohnson
andauthored
Custom domains: don't allow adding a custom domain on subprojects (#8953)
Co-authored-by: Anthony <[email protected]>
1 parent bb45582 commit 134f5ca

File tree

8 files changed

+156
-39
lines changed

8 files changed

+156
-39
lines changed

docs/user/subprojects.rst

+2-7
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,10 @@ https://docs.example.com/projects/bar/en/latest/
4242
Custom domain on subprojects
4343
----------------------------
4444

45-
Adding a custom domain to a subproject is allowed,
46-
but your documentation will always be served from
45+
Adding a custom domain to a subproject is not allowed,
46+
since your documentation will always be served from
4747
the domain of the parent project.
4848

49-
For example, if the domain of a parent project is ``https://docs.example.com``,
50-
and you add the ``https://subproject.example.com/`` domain to one of its subprojects,
51-
it will always redirect to the domain of the parent project
52-
``https://docs.example.com/projects/subproject/``.
53-
5449
Search
5550
------
5651

readthedocs/projects/models.py

+7
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,13 @@ def is_subproject(self):
725725
"""Return whether or not this project is a subproject."""
726726
return self.superprojects.exists()
727727

728+
@property
729+
def superproject(self):
730+
relationship = self.get_parent_relationship()
731+
if relationship:
732+
return relationship.parent
733+
return None
734+
728735
@property
729736
def alias(self):
730737
"""Return the alias (as subproject) if it's a subproject.""" # noqa
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from django.contrib.auth.models import User
2+
from django.test import TestCase, override_settings
3+
from django.urls import reverse
4+
from django_dynamic_fixture import get
5+
6+
from readthedocs.organizations.models import Organization
7+
from readthedocs.projects.models import Domain, Project
8+
9+
10+
@override_settings(RTD_ALLOW_ORGANIZATIONS=False)
11+
class TestDomainViews(TestCase):
12+
def setUp(self):
13+
self.user = get(User, username="user")
14+
self.project = get(Project, users=[self.user], slug="project")
15+
self.subproject = get(Project, users=[self.user], slug="subproject")
16+
self.project.add_subproject(self.subproject)
17+
self.client.force_login(self.user)
18+
19+
def test_domain_creation(self):
20+
self.assertEqual(self.project.domains.count(), 0)
21+
22+
resp = self.client.post(
23+
reverse("projects_domains_create", args=[self.project.slug]),
24+
data={"domain": "test.example.com"},
25+
)
26+
self.assertEqual(resp.status_code, 302)
27+
self.assertEqual(self.project.domains.count(), 1)
28+
29+
domain = self.project.domains.first()
30+
self.assertEqual(domain.domain, "test.example.com")
31+
32+
def test_domain_deletion(self):
33+
domain = get(Domain, project=self.project, domain="test.example.com")
34+
self.assertEqual(self.project.domains.count(), 1)
35+
36+
resp = self.client.post(
37+
reverse("projects_domains_delete", args=[self.project.slug, domain.pk]),
38+
)
39+
self.assertEqual(resp.status_code, 302)
40+
self.assertEqual(self.project.domains.count(), 0)
41+
42+
def test_domain_edit(self):
43+
domain = get(
44+
Domain, project=self.project, domain="test.example.com", canonical=False
45+
)
46+
47+
self.assertEqual(domain.canonical, False)
48+
resp = self.client.post(
49+
reverse("projects_domains_edit", args=[self.project.slug, domain.pk]),
50+
data={"canonical": True},
51+
)
52+
self.assertEqual(resp.status_code, 302)
53+
self.assertEqual(self.project.domains.count(), 1)
54+
55+
domain = self.project.domains.first()
56+
self.assertEqual(domain.domain, "test.example.com")
57+
self.assertEqual(domain.canonical, True)
58+
59+
def test_adding_domain_on_subproject(self):
60+
self.assertEqual(self.subproject.domains.count(), 0)
61+
62+
resp = self.client.post(
63+
reverse("projects_domains_create", args=[self.subproject.slug]),
64+
data={"domain": "test.example.com"},
65+
)
66+
self.assertEqual(resp.status_code, 401)
67+
self.assertEqual(self.subproject.domains.count(), 0)
68+
69+
def test_delete_domain_on_subproject(self):
70+
domain = get(Domain, project=self.subproject, domain="test.example.com")
71+
self.assertEqual(self.subproject.domains.count(), 1)
72+
73+
resp = self.client.post(
74+
reverse("projects_domains_delete", args=[self.subproject.slug, domain.pk]),
75+
)
76+
self.assertEqual(resp.status_code, 302)
77+
self.assertEqual(self.subproject.domains.count(), 0)
78+
79+
def test_edit_domain_on_subproject(self):
80+
domain = get(
81+
Domain, project=self.subproject, domain="test.example.com", canonical=False
82+
)
83+
84+
self.assertEqual(domain.canonical, False)
85+
resp = self.client.post(
86+
reverse("projects_domains_edit", args=[self.subproject.slug, domain.pk]),
87+
data={"canonical": True},
88+
)
89+
self.assertEqual(resp.status_code, 401)
90+
self.assertEqual(self.subproject.domains.count(), 1)
91+
92+
domain = self.subproject.domains.first()
93+
self.assertEqual(domain.domain, "test.example.com")
94+
self.assertEqual(domain.canonical, False)
95+
96+
97+
@override_settings(RTD_ALLOW_ORGANIZATIONS=True)
98+
class TestDomainViewsWithOrganizations(TestDomainViews):
99+
def setUp(self):
100+
super().setUp()
101+
self.org = get(
102+
Organization, owners=[self.user], projects=[self.project, self.subproject]
103+
)

readthedocs/projects/views/base.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
"""Mix-in classes for project views."""
2-
import structlog
32
from functools import lru_cache
43

4+
import structlog
55
from django.conf import settings
6-
from django.http import HttpResponseRedirect
7-
from django.shortcuts import render, get_object_or_404
8-
from django.urls import reverse
6+
from django.shortcuts import get_object_or_404, render
97

108
from readthedocs.projects.models import Project
119

@@ -72,7 +70,9 @@ def get_project(self):
7270
def get_context_data(self, **kwargs):
7371
"""Add project to context data."""
7472
context = super().get_context_data(**kwargs)
75-
context['project'] = self.get_project()
73+
project = self.get_project()
74+
context["project"] = project
75+
context["superproject"] = project and project.superproject
7676
return context
7777

7878
def get_form(self, data=None, files=None, **kwargs):
@@ -92,7 +92,9 @@ class ProjectSpamMixin:
9292

9393
def get(self, request, *args, **kwargs):
9494
if 'readthedocsext.spamfighting' in settings.INSTALLED_APPS:
95-
from readthedocsext.spamfighting.utils import is_show_dashboard_denied # noqa
95+
from readthedocsext.spamfighting.utils import ( # noqa
96+
is_show_dashboard_denied,
97+
)
9698
if is_show_dashboard_denied(self.get_project()):
9799
return render(request, template_name='spam.html', status=410)
98100

readthedocs/projects/views/private.py

+3-6
Original file line numberDiff line numberDiff line change
@@ -435,10 +435,7 @@ def get_success_url(self):
435435

436436
class ProjectRelationshipList(ProjectRelationListMixin, ProjectRelationshipMixin, ListView):
437437

438-
def get_context_data(self, **kwargs):
439-
ctx = super().get_context_data(**kwargs)
440-
ctx['superproject'] = self.project.superprojects.first()
441-
return ctx
438+
pass
442439

443440

444441
class ProjectRelationshipCreate(ProjectRelationshipMixin, CreateView):
@@ -786,7 +783,7 @@ class DomainCreateBase(DomainMixin, CreateView):
786783

787784
def post(self, request, *args, **kwargs):
788785
project = self.get_project()
789-
if self._is_enabled(project):
786+
if self._is_enabled(project) and not project.superproject:
790787
return super().post(request, *args, **kwargs)
791788
return HttpResponse('Action not allowed', status=401)
792789

@@ -810,7 +807,7 @@ class DomainUpdateBase(DomainMixin, UpdateView):
810807

811808
def post(self, request, *args, **kwargs):
812809
project = self.get_project()
813-
if self._is_enabled(project):
810+
if self._is_enabled(project) and not project.superproject:
814811
return super().post(request, *args, **kwargs)
815812
return HttpResponse('Action not allowed', status=401)
816813

readthedocs/templates/projects/domain_form.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
{% endif %}
5050
<form method="post" action="{{ action_url }}">{% csrf_token %}
5151
{{ form.as_p }}
52-
<input type="submit" value="{% trans "Save Domain" %}" {% if not enabled %}disabled{% endif %}>
52+
<input type="submit" value="{% trans "Save Domain" %}" {% if not enabled or superproject %}disabled{% endif %}>
5353
{% if domain.domainssl %}
5454
<p class="help-block">{% trans 'Saving the domain will revalidate the SSL certificate' %}</p>
5555
{% endif %}

readthedocs/templates/projects/domain_list.html

+29-16
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,22 @@
1212
{% block project_edit_content_header %}{% trans "Domains" %}{% endblock %}
1313

1414
{% block project_edit_content %}
15-
<p class="help_text">
16-
{% blocktrans trimmed with docs_url='https://docs.readthedocs.io/page/custom_domains.html' %}
17-
Configuring a custom domain allows you to serve your documentation from a domain other than "{{ default_domain }}". <a href="{{ docs_url }}">Learn more</a>.
18-
{% endblocktrans %}
19-
</p>
15+
{% if superproject %}
16+
{% url "projects_detail" superproject.slug as superproject_url %}
17+
<p>
18+
{% blocktrans trimmed with superproject=superproject.name superproject_url=superproject_url domain=project.subdomain %}
19+
This project is a subproject of <a href="{{ superproject_url }}">{{ superproject }}</a>,
20+
its documentation will always be served from the <code>{{ domain }}</code> domain.
21+
<a href="https://docs.readthedocs.io/page/subprojects.html#custom-domain-on-subprojects">Learn more</a>.
22+
{% endblocktrans %}
23+
</p>
24+
{% else %}
25+
<p class="help_text">
26+
{% blocktrans trimmed with docs_url='https://docs.readthedocs.io/page/custom_domains.html' %}
27+
Configuring a custom domain allows you to serve your documentation from a domain other than "{{ default_domain }}". <a href="{{ docs_url }}">Learn more</a>.
28+
{% endblocktrans %}
29+
</p>
30+
{% endif %}
2031

2132
{% if object_list %}
2233
<h3> {% trans "Existing Domains" %} </h3>
@@ -33,16 +44,18 @@ <h3> {% trans "Existing Domains" %} </h3>
3344
</p>
3445
{% endif %}
3546

36-
<h3> {% trans "Add new Domain" %} </h3>
37-
38-
{% if not enabled %}
39-
{% include 'projects/includes/feature_disabled.html' with project=project %}
40-
{% else %}
41-
<form method="post" action="{% url 'projects_domains_create' project.slug %}">{% csrf_token %}
42-
{{ form.as_p }}
43-
<p>
44-
<input style="display: inline;" type="submit" value="{% trans "Add" %}">
45-
</p>
46-
</form>
47+
{% if not superproject %}
48+
<h3>{% trans "Add new domain" %}</h3>
49+
50+
{% if not enabled %}
51+
{% include 'projects/includes/feature_disabled.html' with project=project %}
52+
{% else %}
53+
<form method="post" action="{% url 'projects_domains_create' project.slug %}">{% csrf_token %}
54+
{{ form.as_p }}
55+
<p>
56+
<input style="display: inline;" type="submit" value="{% trans "Add" %}">
57+
</p>
58+
</form>
59+
{% endif %}
4760
{% endif %}
4861
{% endblock %}

readthedocs/templates/projects/projectrelationship_list.html

+3-3
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@
2121

2222
{% if superproject %}
2323
<p>
24-
{% blocktrans trimmed with project=superproject.parent.name %}
24+
{% blocktrans trimmed with project=superproject.name %}
2525
This project is already configured as a subproject of {{ project }}.
2626
Nested subprojects are not currently supported.
2727
{% endblocktrans %}
2828
</p>
2929

30-
<a href="{% url 'projects_subprojects' project_slug=superproject.parent.slug %}">
31-
{% blocktrans trimmed with project=superproject.parent.name %}
30+
<a href="{% url 'projects_subprojects' project_slug=superproject.slug %}">
31+
{% blocktrans trimmed with project=superproject.name %}
3232
View subprojects of {{ project }}
3333
{% endblocktrans %}
3434
</a>

0 commit comments

Comments
 (0)