diff --git a/docs/user/subprojects.rst b/docs/user/subprojects.rst index d734246d001..41753175137 100644 --- a/docs/user/subprojects.rst +++ b/docs/user/subprojects.rst @@ -42,15 +42,10 @@ https://docs.example.com/projects/bar/en/latest/ Custom domain on subprojects ---------------------------- -Adding a custom domain to a subproject is allowed, -but your documentation will always be served from +Adding a custom domain to a subproject is not allowed, +since your documentation will always be served from the domain of the parent project. -For example, if the domain of a parent project is ``https://docs.example.com``, -and you add the ``https://subproject.example.com/`` domain to one of its subprojects, -it will always redirect to the domain of the parent project -``https://docs.example.com/projects/subproject/``. - Search ------ diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 8b6c38795a6..0064e548499 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -725,6 +725,13 @@ def is_subproject(self): """Return whether or not this project is a subproject.""" return self.superprojects.exists() + @property + def superproject(self): + relationship = self.get_parent_relationship() + if relationship: + return relationship.parent + return None + @property def alias(self): """Return the alias (as subproject) if it's a subproject.""" # noqa diff --git a/readthedocs/projects/tests/test_domain_views.py b/readthedocs/projects/tests/test_domain_views.py new file mode 100644 index 00000000000..f5e0a7bbe23 --- /dev/null +++ b/readthedocs/projects/tests/test_domain_views.py @@ -0,0 +1,103 @@ +from django.contrib.auth.models import User +from django.test import TestCase, override_settings +from django.urls import reverse +from django_dynamic_fixture import get + +from readthedocs.organizations.models import Organization +from readthedocs.projects.models import Domain, Project + + +@override_settings(RTD_ALLOW_ORGANIZATIONS=False) +class TestDomainViews(TestCase): + def setUp(self): + self.user = get(User, username="user") + self.project = get(Project, users=[self.user], slug="project") + self.subproject = get(Project, users=[self.user], slug="subproject") + self.project.add_subproject(self.subproject) + self.client.force_login(self.user) + + def test_domain_creation(self): + self.assertEqual(self.project.domains.count(), 0) + + resp = self.client.post( + reverse("projects_domains_create", args=[self.project.slug]), + data={"domain": "test.example.com"}, + ) + self.assertEqual(resp.status_code, 302) + self.assertEqual(self.project.domains.count(), 1) + + domain = self.project.domains.first() + self.assertEqual(domain.domain, "test.example.com") + + def test_domain_deletion(self): + domain = get(Domain, project=self.project, domain="test.example.com") + self.assertEqual(self.project.domains.count(), 1) + + resp = self.client.post( + reverse("projects_domains_delete", args=[self.project.slug, domain.pk]), + ) + self.assertEqual(resp.status_code, 302) + self.assertEqual(self.project.domains.count(), 0) + + def test_domain_edit(self): + domain = get( + Domain, project=self.project, domain="test.example.com", canonical=False + ) + + self.assertEqual(domain.canonical, False) + resp = self.client.post( + reverse("projects_domains_edit", args=[self.project.slug, domain.pk]), + data={"canonical": True}, + ) + self.assertEqual(resp.status_code, 302) + self.assertEqual(self.project.domains.count(), 1) + + domain = self.project.domains.first() + self.assertEqual(domain.domain, "test.example.com") + self.assertEqual(domain.canonical, True) + + def test_adding_domain_on_subproject(self): + self.assertEqual(self.subproject.domains.count(), 0) + + resp = self.client.post( + reverse("projects_domains_create", args=[self.subproject.slug]), + data={"domain": "test.example.com"}, + ) + self.assertEqual(resp.status_code, 401) + self.assertEqual(self.subproject.domains.count(), 0) + + def test_delete_domain_on_subproject(self): + domain = get(Domain, project=self.subproject, domain="test.example.com") + self.assertEqual(self.subproject.domains.count(), 1) + + resp = self.client.post( + reverse("projects_domains_delete", args=[self.subproject.slug, domain.pk]), + ) + self.assertEqual(resp.status_code, 302) + self.assertEqual(self.subproject.domains.count(), 0) + + def test_edit_domain_on_subproject(self): + domain = get( + Domain, project=self.subproject, domain="test.example.com", canonical=False + ) + + self.assertEqual(domain.canonical, False) + resp = self.client.post( + reverse("projects_domains_edit", args=[self.subproject.slug, domain.pk]), + data={"canonical": True}, + ) + self.assertEqual(resp.status_code, 401) + self.assertEqual(self.subproject.domains.count(), 1) + + domain = self.subproject.domains.first() + self.assertEqual(domain.domain, "test.example.com") + self.assertEqual(domain.canonical, False) + + +@override_settings(RTD_ALLOW_ORGANIZATIONS=True) +class TestDomainViewsWithOrganizations(TestDomainViews): + def setUp(self): + super().setUp() + self.org = get( + Organization, owners=[self.user], projects=[self.project, self.subproject] + ) diff --git a/readthedocs/projects/views/base.py b/readthedocs/projects/views/base.py index ba76f7c137f..2ba29212e06 100644 --- a/readthedocs/projects/views/base.py +++ b/readthedocs/projects/views/base.py @@ -1,11 +1,9 @@ """Mix-in classes for project views.""" -import structlog from functools import lru_cache +import structlog from django.conf import settings -from django.http import HttpResponseRedirect -from django.shortcuts import render, get_object_or_404 -from django.urls import reverse +from django.shortcuts import get_object_or_404, render from readthedocs.projects.models import Project @@ -72,7 +70,9 @@ def get_project(self): def get_context_data(self, **kwargs): """Add project to context data.""" context = super().get_context_data(**kwargs) - context['project'] = self.get_project() + project = self.get_project() + context["project"] = project + context["superproject"] = project and project.superproject return context def get_form(self, data=None, files=None, **kwargs): @@ -92,7 +92,9 @@ class ProjectSpamMixin: def get(self, request, *args, **kwargs): if 'readthedocsext.spamfighting' in settings.INSTALLED_APPS: - from readthedocsext.spamfighting.utils import is_show_dashboard_denied # noqa + from readthedocsext.spamfighting.utils import ( # noqa + is_show_dashboard_denied, + ) if is_show_dashboard_denied(self.get_project()): return render(request, template_name='spam.html', status=410) diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py index e65abe90ce5..0d20044776b 100644 --- a/readthedocs/projects/views/private.py +++ b/readthedocs/projects/views/private.py @@ -435,10 +435,7 @@ def get_success_url(self): class ProjectRelationshipList(ProjectRelationListMixin, ProjectRelationshipMixin, ListView): - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - ctx['superproject'] = self.project.superprojects.first() - return ctx + pass class ProjectRelationshipCreate(ProjectRelationshipMixin, CreateView): @@ -786,7 +783,7 @@ class DomainCreateBase(DomainMixin, CreateView): def post(self, request, *args, **kwargs): project = self.get_project() - if self._is_enabled(project): + if self._is_enabled(project) and not project.superproject: return super().post(request, *args, **kwargs) return HttpResponse('Action not allowed', status=401) @@ -810,7 +807,7 @@ class DomainUpdateBase(DomainMixin, UpdateView): def post(self, request, *args, **kwargs): project = self.get_project() - if self._is_enabled(project): + if self._is_enabled(project) and not project.superproject: return super().post(request, *args, **kwargs) return HttpResponse('Action not allowed', status=401) diff --git a/readthedocs/templates/projects/domain_form.html b/readthedocs/templates/projects/domain_form.html index debe65ef8bc..4c292474a23 100644 --- a/readthedocs/templates/projects/domain_form.html +++ b/readthedocs/templates/projects/domain_form.html @@ -49,7 +49,7 @@ {% endif %}
{% csrf_token %} {{ form.as_p }} - + {% if domain.domainssl %}

{% trans 'Saving the domain will revalidate the SSL certificate' %}

{% endif %} diff --git a/readthedocs/templates/projects/domain_list.html b/readthedocs/templates/projects/domain_list.html index 500570bd507..787c7997149 100644 --- a/readthedocs/templates/projects/domain_list.html +++ b/readthedocs/templates/projects/domain_list.html @@ -12,11 +12,22 @@ {% block project_edit_content_header %}{% trans "Domains" %}{% endblock %} {% block project_edit_content %} -

- {% blocktrans trimmed with docs_url='https://docs.readthedocs.io/page/custom_domains.html' %} - Configuring a custom domain allows you to serve your documentation from a domain other than "{{ default_domain }}". Learn more. - {% endblocktrans %} -

+ {% if superproject %} + {% url "projects_detail" superproject.slug as superproject_url %} +

+ {% blocktrans trimmed with superproject=superproject.name superproject_url=superproject_url domain=project.subdomain %} + This project is a subproject of {{ superproject }}, + its documentation will always be served from the {{ domain }} domain. + Learn more. + {% endblocktrans %} +

+ {% else %} +

+ {% blocktrans trimmed with docs_url='https://docs.readthedocs.io/page/custom_domains.html' %} + Configuring a custom domain allows you to serve your documentation from a domain other than "{{ default_domain }}". Learn more. + {% endblocktrans %} +

+ {% endif %} {% if object_list %}

{% trans "Existing Domains" %}

@@ -33,16 +44,18 @@

{% trans "Existing Domains" %}

{% endif %} -

{% trans "Add new Domain" %}

- - {% if not enabled %} - {% include 'projects/includes/feature_disabled.html' with project=project %} - {% else %} - {% csrf_token %} - {{ form.as_p }} -

- -

-
+ {% if not superproject %} +

{% trans "Add new domain" %}

+ + {% if not enabled %} + {% include 'projects/includes/feature_disabled.html' with project=project %} + {% else %} +
{% csrf_token %} + {{ form.as_p }} +

+ +

+
+ {% endif %} {% endif %} {% endblock %} diff --git a/readthedocs/templates/projects/projectrelationship_list.html b/readthedocs/templates/projects/projectrelationship_list.html index d5a5894eef8..de4e0be73ba 100644 --- a/readthedocs/templates/projects/projectrelationship_list.html +++ b/readthedocs/templates/projects/projectrelationship_list.html @@ -21,14 +21,14 @@ {% if superproject %}

- {% blocktrans trimmed with project=superproject.parent.name %} + {% blocktrans trimmed with project=superproject.name %} This project is already configured as a subproject of {{ project }}. Nested subprojects are not currently supported. {% endblocktrans %}

- - {% blocktrans trimmed with project=superproject.parent.name %} + + {% blocktrans trimmed with project=superproject.name %} View subprojects of {{ project }} {% endblocktrans %}