diff --git a/readthedocs/api/v3/tests/test_redirects.py b/readthedocs/api/v3/tests/test_redirects.py index 3a14cb63c29..1c1ad4e6436 100644 --- a/readthedocs/api/v3/tests/test_redirects.py +++ b/readthedocs/api/v3/tests/test_redirects.py @@ -3,6 +3,7 @@ from readthedocs.redirects.constants import ( CLEAN_URL_TO_HTML_REDIRECT, + CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, EXACT_REDIRECT, HTML_TO_CLEAN_URL_REDIRECT, ) @@ -172,6 +173,30 @@ def test_projects_redirects_type_clean_url_to_html_list_post(self): self.assertEqual(redirect.from_url, "") self.assertEqual(redirect.to_url, "") + def test_projects_redirects_type_clean_url_without_trailing_slash_to_html_list_post(self): + self.assertEqual(Redirect.objects.count(), 1) + data = { + "type": CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, + } + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") + response = self.client.post( + reverse( + "projects-redirects-list", + kwargs={ + "parent_lookup_project__slug": self.project.slug, + }, + ), + data, + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(Redirect.objects.all().count(), 2) + + redirect = Redirect.objects.first() + self.assertEqual(redirect.redirect_type, CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT) + self.assertEqual(redirect.from_url, "") + self.assertEqual(redirect.to_url, "") + def test_projects_redirects_detail_put(self): url = reverse( "projects-redirects-detail", @@ -347,6 +372,19 @@ def test_projects_redirects_validations(self): ["Only one redirect of type `clean_url_to_html` is allowed per project."], ) + get(Redirect, redirect_type=CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, project=self.project) + response = self.client.post( + url, + data={ + "type": CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + ["Only one redirect of type `clean_url_without_trailing_slash_to_html` is allowed per project."], + ) + get(Redirect, redirect_type=HTML_TO_CLEAN_URL_REDIRECT, project=self.project) response = self.client.post( url, diff --git a/readthedocs/proxito/tests/test_old_redirects.py b/readthedocs/proxito/tests/test_old_redirects.py index 52809377162..703fbbe8503 100644 --- a/readthedocs/proxito/tests/test_old_redirects.py +++ b/readthedocs/proxito/tests/test_old_redirects.py @@ -17,6 +17,7 @@ from readthedocs.projects.constants import SINGLE_VERSION_WITHOUT_TRANSLATIONS from readthedocs.projects.models import Feature from readthedocs.redirects.constants import ( + CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, CLEAN_URL_TO_HTML_REDIRECT, EXACT_REDIRECT, HTML_TO_CLEAN_URL_REDIRECT, @@ -1241,6 +1242,22 @@ def test_redirect_html_index(self): "http://project.dev.readthedocs.io/en/latest/faq.html", ) + def test_redirect_html_without_trailing_slash(self): + fixture.get( + Redirect, + project=self.project, + redirect_type=CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, + force=True, + ) + r = self.client.get( + "/en/latest/guides/faq", headers={"host": "project.dev.readthedocs.io"} + ) + self.assertEqual(r.status_code, 302) + self.assertEqual( + r["Location"], + "http://project.dev.readthedocs.io/en/latest/guides/faq.html", + ) + def test_redirect_htmldir(self): fixture.get( Redirect, diff --git a/readthedocs/redirects/constants.py b/readthedocs/redirects/constants.py index 617b8051b69..2df22c1a6c0 100644 --- a/readthedocs/redirects/constants.py +++ b/readthedocs/redirects/constants.py @@ -9,11 +9,16 @@ PAGE_REDIRECT = "page" EXACT_REDIRECT = "exact" CLEAN_URL_TO_HTML_REDIRECT = "clean_url_to_html" +CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT = "clean_url_without_trailing_slash_to_html" HTML_TO_CLEAN_URL_REDIRECT = "html_to_clean_url" TYPE_CHOICES = ( (PAGE_REDIRECT, _("Page Redirect")), (EXACT_REDIRECT, _("Exact Redirect")), (CLEAN_URL_TO_HTML_REDIRECT, _("Clean URL to HTML (file/ to file.html)")), + ( + CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, + _("Clean URL, without trailing slash, to HTML (file to file.html)"), + ), (HTML_TO_CLEAN_URL_REDIRECT, _("HTML to clean URL (file.html to file/)")), ) diff --git a/readthedocs/redirects/migrations/0010_new_clean_without_trailing_slash_redirect.py b/readthedocs/redirects/migrations/0010_new_clean_without_trailing_slash_redirect.py new file mode 100644 index 00000000000..4049426ef77 --- /dev/null +++ b/readthedocs/redirects/migrations/0010_new_clean_without_trailing_slash_redirect.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.4 on 2025-07-15 13:25 + +from django.db import migrations +from django.db import models +from django_safemigrate import Safe + + +class Migration(migrations.Migration): + safe = Safe.before_deploy() + dependencies = [ + ("redirects", "0009_remove_status_field"), + ] + + operations = [ + migrations.AlterField( + model_name="redirect", + name="redirect_type", + field=models.CharField( + choices=[ + ("page", "Page Redirect"), + ("exact", "Exact Redirect"), + ("clean_url_to_html", "Clean URL to HTML (file/ to file.html)"), + ( + "clean_url_without_trailing_slash_to_html", + "Clean URL, without trailing slash, to HTML (file to file.html)", + ), + ("html_to_clean_url", "HTML to clean URL (file.html to file/)"), + ], + help_text="The type of redirect you wish to use.", + max_length=255, + verbose_name="Redirect Type", + ), + ), + ] diff --git a/readthedocs/redirects/models.py b/readthedocs/redirects/models.py index 38602af657a..7f9c0bae40a 100644 --- a/readthedocs/redirects/models.py +++ b/readthedocs/redirects/models.py @@ -1,5 +1,6 @@ """Django models for the redirects app.""" +import os import re import structlog @@ -11,6 +12,7 @@ from readthedocs.projects.models import Project from readthedocs.projects.ordering import ProjectItemPositionManager from readthedocs.redirects.constants import CLEAN_URL_TO_HTML_REDIRECT +from readthedocs.redirects.constants import CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT from readthedocs.redirects.constants import EXACT_REDIRECT from readthedocs.redirects.constants import HTML_TO_CLEAN_URL_REDIRECT from readthedocs.redirects.constants import HTTP_STATUS_CHOICES @@ -134,6 +136,7 @@ def save(self, *args, **kwargs): self.from_url_without_rest = None if self.redirect_type in [ CLEAN_URL_TO_HTML_REDIRECT, + CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, HTML_TO_CLEAN_URL_REDIRECT, ]: # These redirects don't make use of the ``from_url``/``to_url`` fields. @@ -324,6 +327,20 @@ def redirect_clean_url_to_html(self, filename, path, language=None, version_slug allow_crossdomain=False, ) + def redirect_clean_url_without_trailing_slash_to_html( + self, filename, path, language=None, version_slug=None + ): + log.debug("Redirecting...", redirect=self) + name, ext = os.path.splitext(filename) + if not ext: + to = name + ".html" + return self.get_full_path( + filename=to, + language=language, + version_slug=version_slug, + allow_crossdomain=False, + ) + def redirect_html_to_clean_url(self, filename, path, language=None, version_slug=None): log.debug("Redirecting...", redirect=self) to = filename.removesuffix(".html") + "/" diff --git a/readthedocs/redirects/querysets.py b/readthedocs/redirects/querysets.py index a134d27a45e..a0eef5ab05c 100644 --- a/readthedocs/redirects/querysets.py +++ b/readthedocs/redirects/querysets.py @@ -12,6 +12,7 @@ from readthedocs.core.permissions import AdminPermission from readthedocs.core.querysets import NoReprQuerySet from readthedocs.redirects.constants import CLEAN_URL_TO_HTML_REDIRECT +from readthedocs.redirects.constants import CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT from readthedocs.redirects.constants import EXACT_REDIRECT from readthedocs.redirects.constants import HTML_TO_CLEAN_URL_REDIRECT from readthedocs.redirects.constants import PAGE_REDIRECT @@ -110,6 +111,9 @@ def get_matching_redirect_with_path( path__startswith=F("from_url_without_rest"), ) clean_url_to_html = Q(redirect_type=CLEAN_URL_TO_HTML_REDIRECT) + clean_url_without_trailing_slash_to_html = Q( + redirect_type=CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT + ) html_to_clean_url = Q(redirect_type=HTML_TO_CLEAN_URL_REDIRECT) if filename in ["/index.html", "/"]: @@ -122,7 +126,7 @@ def get_matching_redirect_with_path( elif filename.endswith(".html"): queryset = queryset.filter(page | exact | html_to_clean_url) else: - queryset = queryset.filter(page | exact) + queryset = queryset.filter(page | exact | clean_url_without_trailing_slash_to_html) else: # If the filename is empty, we only need to match exact redirects. # Since the other types of redirects are not valid without a filename. diff --git a/readthedocs/redirects/tests/test_views.py b/readthedocs/redirects/tests/test_views.py index d7510237cd2..69258940544 100644 --- a/readthedocs/redirects/tests/test_views.py +++ b/readthedocs/redirects/tests/test_views.py @@ -7,6 +7,7 @@ from readthedocs.projects.models import Project from readthedocs.redirects.constants import ( CLEAN_URL_TO_HTML_REDIRECT, + CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, EXACT_REDIRECT, HTML_TO_CLEAN_URL_REDIRECT, PAGE_REDIRECT, @@ -210,6 +211,20 @@ def test_redirects_validations(self): "Only one redirect of type `clean_url_to_html` is allowed per project.", ) + get(Redirect, redirect_type=CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, project=self.project) + resp = self.client.post( + url, + data={ + "redirect_type": CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, + "http_status": 302, + }, + ) + self.assertEqual(resp.status_code, 200) + self.assertContains( + resp, + "Only one redirect of type `clean_url_without_trailing_slash_to_html` is allowed per project.", + ) + get(Redirect, redirect_type=HTML_TO_CLEAN_URL_REDIRECT, project=self.project) resp = self.client.post( url, diff --git a/readthedocs/redirects/validators.py b/readthedocs/redirects/validators.py index cdd664d9ddd..755cd65d86e 100644 --- a/readthedocs/redirects/validators.py +++ b/readthedocs/redirects/validators.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _ from readthedocs.redirects.constants import CLEAN_URL_TO_HTML_REDIRECT +from readthedocs.redirects.constants import CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT from readthedocs.redirects.constants import EXACT_REDIRECT from readthedocs.redirects.constants import HTML_TO_CLEAN_URL_REDIRECT from readthedocs.redirects.constants import PAGE_REDIRECT @@ -32,7 +33,11 @@ def validate_redirect(*, project, pk, redirect_type, from_url, to_url, error_cla "The * wildcard must be at the end of from_url to use the :splat placeholder in to_url." ) - if redirect_type in [CLEAN_URL_TO_HTML_REDIRECT, HTML_TO_CLEAN_URL_REDIRECT]: + if redirect_type in [ + CLEAN_URL_TO_HTML_REDIRECT, + CLEAN_URL_WITHOUT_TRAILING_SLASH_TO_HTML_REDIRECT, + HTML_TO_CLEAN_URL_REDIRECT, + ]: redirect_exists = ( project.redirects.filter(redirect_type=redirect_type).exclude(pk=pk).exists() )