diff --git a/readthedocs/api/v3/tests/test_versions.py b/readthedocs/api/v3/tests/test_versions.py index d8374c2aeb4..33475f4b313 100644 --- a/readthedocs/api/v3/tests/test_versions.py +++ b/readthedocs/api/v3/tests/test_versions.py @@ -28,8 +28,8 @@ def test_projects_versions_list_anonymous_user(self): self.assertEqual(response.status_code, 200) json_data = response.json() self.assertEqual(len(json_data["results"]), 2) - self.assertEqual(json_data["results"][0]["slug"], "v1.0") - self.assertEqual(json_data["results"][1]["slug"], "latest") + self.assertEqual(json_data["results"][0]["slug"], "latest") + self.assertEqual(json_data["results"][1]["slug"], "v1.0") # Versions are private self.project.versions.update(privacy_level=PRIVATE) @@ -54,16 +54,16 @@ def test_projects_versions_list(self): self.assertEqual(response.status_code, 200) response = response.json() self.assertEqual(len(response["results"]), 2) - self.assertEqual(response["results"][0]["slug"], "v1.0") - self.assertEqual(response["results"][1]["slug"], "latest") + self.assertEqual(response["results"][0]["slug"], "latest") + self.assertEqual(response["results"][1]["slug"], "v1.0") # Versions are private Project.objects.filter(slug=self.project.slug).update(privacy_level=PRIVATE) response = self.client.get(url) response = response.json() self.assertEqual(len(response["results"]), 2) - self.assertEqual(response["results"][0]["slug"], "v1.0") - self.assertEqual(response["results"][1]["slug"], "latest") + self.assertEqual(response["results"][0]["slug"], "latest") + self.assertEqual(response["results"][1]["slug"], "v1.0") def test_projects_versions_list_other_user(self): url = reverse( @@ -80,8 +80,8 @@ def test_projects_versions_list_other_user(self): self.assertEqual(response.status_code, 200) json_data = response.json() self.assertEqual(len(json_data["results"]), 2) - self.assertEqual(json_data["results"][0]["slug"], "v1.0") - self.assertEqual(json_data["results"][1]["slug"], "latest") + self.assertEqual(json_data["results"][0]["slug"], "latest") + self.assertEqual(json_data["results"][1]["slug"], "v1.0") # Versions are private self.project.versions.update(privacy_level=PRIVATE) diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index 2c73717f088..c76f0d80670 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -380,8 +380,12 @@ def update(self, request, *args, **kwargs): return result def get_queryset(self): - """Overridden to allow internal versions only.""" - return super().get_queryset().exclude(type=EXTERNAL) + """Overridden to allow internal versions only. + + Orders results with "latest" first, "stable" second, + then remaining versions in ascending alphabetical order. + """ + return super().get_queryset().exclude(type=EXTERNAL).sort_version_aware_naive() class BuildsViewSet( diff --git a/readthedocs/builds/filters.py b/readthedocs/builds/filters.py index 006b8c67f6a..fd4510548d5 100644 --- a/readthedocs/builds/filters.py +++ b/readthedocs/builds/filters.py @@ -67,8 +67,10 @@ def get_version_queryset(self): # Copied from the version listing view. We need this here as this is # what allows the build version list to populate. Otherwise the # ``all()`` queryset method is used. - return self.project.versions(manager=INTERNAL).public( - user=self.request.user, + return ( + self.project.versions(manager=INTERNAL) + .public(user=self.request.user) + .sort_version_aware_naive() ) def get_state(self, queryset, _, value): diff --git a/readthedocs/builds/migrations/0071_version_ordering_ascending.py b/readthedocs/builds/migrations/0071_version_ordering_ascending.py new file mode 100644 index 00000000000..6974173cda1 --- /dev/null +++ b/readthedocs/builds/migrations/0071_version_ordering_ascending.py @@ -0,0 +1,17 @@ +from django.db import migrations +from django_safemigrate import Safe + + +class Migration(migrations.Migration): + safe = Safe.always() + + dependencies = [ + ("builds", "0070_delete_build_old_config"), + ] + + operations = [ + migrations.AlterModelOptions( + name="version", + options={"ordering": ["verbose_name"]}, + ), + ] diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index 30bde8f5162..dcc07589fbe 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -193,7 +193,7 @@ class Version(TimeStampedModel): class Meta: unique_together = [("project", "slug")] - ordering = ["-verbose_name"] + ordering = ["verbose_name"] def __str__(self): return self.verbose_name diff --git a/readthedocs/builds/querysets.py b/readthedocs/builds/querysets.py index c244ee27226..247faaad0f6 100644 --- a/readthedocs/builds/querysets.py +++ b/readthedocs/builds/querysets.py @@ -4,13 +4,18 @@ import structlog from django.db import models +from django.db.models import Case from django.db.models import Q +from django.db.models import Value +from django.db.models import When from django.utils import timezone from readthedocs.builds.constants import BUILD_STATE_CANCELLED from readthedocs.builds.constants import BUILD_STATE_FINISHED from readthedocs.builds.constants import BUILD_STATE_TRIGGERED from readthedocs.builds.constants import EXTERNAL +from readthedocs.builds.constants import LATEST_VERBOSE_NAME +from readthedocs.builds.constants import STABLE_VERBOSE_NAME from readthedocs.core.permissions import AdminPermission from readthedocs.core.querysets import NoReprQuerySet from readthedocs.core.utils.extend import SettingsOverrideObject @@ -146,6 +151,24 @@ def for_reindex(self): .distinct() ) + def sort_version_aware_naive(self): + """ + Order versions with "latest" first, "stable" second, then alphabetically. + + This is a naive SQL-level approximation of the full version-aware + sorting (which requires Python-level semantic version parsing). + It ensures special versions appear at the top, but does not handle + semantic version ordering (e.g. 1.9 vs 1.10). + """ + return self.order_by( + Case( + When(verbose_name=LATEST_VERBOSE_NAME, then=Value(0)), + When(verbose_name=STABLE_VERBOSE_NAME, then=Value(1)), + default=Value(2), + ), + "verbose_name", + ) + class VersionQuerySet(SettingsOverrideObject): _default_class = VersionQuerySetBase diff --git a/readthedocs/oauth/tests/test_githubapp_webhook.py b/readthedocs/oauth/tests/test_githubapp_webhook.py index 2b1a3e9d5c8..07aca1e2aee 100644 --- a/readthedocs/oauth/tests/test_githubapp_webhook.py +++ b/readthedocs/oauth/tests/test_githubapp_webhook.py @@ -469,7 +469,8 @@ def test_push_branch(self, trigger_build): [ mock.call(project=self.project, version=self.version_main, from_webhook=True), mock.call(project=self.project, version=self.version_latest, from_webhook=True), - ] + ], + any_order=True, ) @mock.patch("readthedocs.core.views.hooks.trigger_build") @@ -1205,7 +1206,8 @@ def test_push_branch_without_webhook_rules(self, trigger_build): [ mock.call(project=self.project, version=self.version_main, from_webhook=True), mock.call(project=self.project, version=self.version_latest, from_webhook=True), - ] + ], + any_order=True, ) @mock.patch("readthedocs.builds.automation_actions.trigger_build") diff --git a/readthedocs/projects/templatetags/projects_tags.py b/readthedocs/projects/templatetags/projects_tags.py index c574430c1ae..c04417367ce 100644 --- a/readthedocs/projects/templatetags/projects_tags.py +++ b/readthedocs/projects/templatetags/projects_tags.py @@ -18,7 +18,10 @@ def sort_version_aware(versions): repo_type = versions[0].project.repo_type return sorted( versions, - key=lambda version: comparable_version(version.verbose_name, repo_type=repo_type), + key=lambda version: ( + comparable_version(version.verbose_name, repo_type=repo_type), + version.verbose_name, + ), reverse=True, ) diff --git a/readthedocs/proxito/tests/test_full.py b/readthedocs/proxito/tests/test_full.py index a0148dd6379..095854728fd 100644 --- a/readthedocs/proxito/tests/test_full.py +++ b/readthedocs/proxito/tests/test_full.py @@ -903,10 +903,10 @@ def test_default_robots_txt_disallow_hidden_versions(self, storage_exists): """ User-agent: * - Disallow: /en/hidden-2/ # Hidden version - Disallow: /en/hidden/ # Hidden version + Disallow: /en/hidden-2/ # Hidden version + Sitemap: https://project.readthedocs.io/sitemap.xml """ ).lstrip() diff --git a/readthedocs/search/tests/conftest.py b/readthedocs/search/tests/conftest.py index 8c11ad4e391..f19c7b64e36 100644 --- a/readthedocs/search/tests/conftest.py +++ b/readthedocs/search/tests/conftest.py @@ -7,6 +7,7 @@ from django_dynamic_fixture import get from readthedocs.builds.constants import STABLE +from readthedocs.builds.constants import STABLE_VERBOSE_NAME from readthedocs.builds.models import Version from readthedocs.projects.constants import PUBLIC from readthedocs.projects.models import HTMLFile, Project @@ -41,6 +42,7 @@ def all_projects(es_index, mock_processed_json, db, settings): Version, project=project, slug=STABLE, + verbose_name=STABLE_VERBOSE_NAME, built=True, active=True, ) diff --git a/readthedocs/search/tests/test_api.py b/readthedocs/search/tests/test_api.py index e73ba344e59..cbf1bbd5649 100644 --- a/readthedocs/search/tests/test_api.py +++ b/readthedocs/search/tests/test_api.py @@ -346,9 +346,9 @@ def test_doc_search_hidden_versions(self, api_client, all_projects): # Add another project as subproject of the project project.add_subproject(subproject) + # Hide all versions of the subproject + subproject.versions.update(hidden=True) version_subproject = subproject.versions.first() - version_subproject.hidden = True - version_subproject.save() # Now search with subproject content but explicitly filter by the parent project query = get_search_query_from_project_file(project_slug=subproject.slug) @@ -398,7 +398,7 @@ def test_search_correct_link_for_normal_page_html_projects( result = resp.data["results"][0] assert result["project"] == project.slug - assert result["path"] == "/en/latest/support.html" + assert result["path"] == f"/en/{version.slug}/support.html" @pytest.mark.parametrize("doctype", [SPHINX, SPHINX_SINGLEHTML, MKDOCS_HTML]) def test_search_correct_link_for_index_page_html_projects( @@ -423,7 +423,7 @@ def test_search_correct_link_for_index_page_html_projects( result = resp.data["results"][0] assert result["project"] == project.slug - assert result["path"] == "/en/latest/index.html" + assert result["path"] == f"/en/{version.slug}/index.html" @pytest.mark.parametrize("doctype", [SPHINX, SPHINX_SINGLEHTML, MKDOCS_HTML]) def test_search_correct_link_for_index_page_subdirectory_html_projects( @@ -448,7 +448,7 @@ def test_search_correct_link_for_index_page_subdirectory_html_projects( result = resp.data["results"][0] assert result["project"] == project.slug - assert result["path"] == "/en/latest/guides/index.html" + assert result["path"] == f"/en/{version.slug}/guides/index.html" @pytest.mark.parametrize("doctype", [SPHINX_HTMLDIR, MKDOCS]) def test_search_correct_link_for_normal_page_htmldir_projects( @@ -473,7 +473,7 @@ def test_search_correct_link_for_normal_page_htmldir_projects( result = resp.data["results"][0] assert result["project"] == project.slug - assert result["path"] == "/en/latest/support.html" + assert result["path"] == f"/en/{version.slug}/support.html" @pytest.mark.parametrize("doctype", [SPHINX_HTMLDIR, MKDOCS]) def test_search_correct_link_for_index_page_htmldir_projects( @@ -498,7 +498,7 @@ def test_search_correct_link_for_index_page_htmldir_projects( result = resp.data["results"][0] assert result["project"] == project.slug - assert result["path"] == "/en/latest/" + assert result["path"] == f"/en/{version.slug}/" @pytest.mark.parametrize("doctype", [SPHINX_HTMLDIR, MKDOCS]) def test_search_correct_link_for_index_page_subdirectory_htmldir_projects( @@ -523,7 +523,7 @@ def test_search_correct_link_for_index_page_subdirectory_htmldir_projects( result = resp.data["results"][0] assert result["project"] == project.slug - assert result["path"] == "/en/latest/guides/" + assert result["path"] == f"/en/{version.slug}/guides/" def test_search_advanced_query_detection(self, api_client): project = Project.objects.get(slug="docs") @@ -652,8 +652,8 @@ def test_search_custom_ranking(self, api_client): results = resp.data["results"] assert len(results) == 2 - assert results[0]["path"] == "/en/latest/index.html" - assert results[1]["path"] == "/en/latest/guides/index.html" + assert results[0]["path"] == f"/en/{version.slug}/index.html" + assert results[1]["path"] == f"/en/{version.slug}/guides/index.html" # Query with a higher rank over guides/index.html page_guides.rank = 5 @@ -670,8 +670,8 @@ def test_search_custom_ranking(self, api_client): results = resp.data["results"] assert len(results) == 2 - assert results[0]["path"] == "/en/latest/guides/index.html" - assert results[1]["path"] == "/en/latest/index.html" + assert results[0]["path"] == f"/en/{version.slug}/guides/index.html" + assert results[1]["path"] == f"/en/{version.slug}/index.html" # Query with a lower rank over index.html page_index.rank = -2 @@ -691,8 +691,8 @@ def test_search_custom_ranking(self, api_client): results = resp.data["results"] assert len(results) == 2 - assert results[0]["path"] == "/en/latest/guides/index.html" - assert results[1]["path"] == "/en/latest/index.html" + assert results[0]["path"] == f"/en/{version.slug}/guides/index.html" + assert results[1]["path"] == f"/en/{version.slug}/index.html" # Query with a lower rank over index.html page_index.rank = 3 @@ -712,8 +712,8 @@ def test_search_custom_ranking(self, api_client): results = resp.data["results"] assert len(results) == 2 - assert results[0]["path"] == "/en/latest/guides/index.html" - assert results[1]["path"] == "/en/latest/index.html" + assert results[0]["path"] == f"/en/{version.slug}/guides/index.html" + assert results[1]["path"] == f"/en/{version.slug}/index.html" # Query with a same rank over guides/index.html and index.html page_index.rank = -10 @@ -733,8 +733,8 @@ def test_search_custom_ranking(self, api_client): results = resp.data["results"] assert len(results) == 2 - assert results[0]["path"] == "/en/latest/index.html" - assert results[1]["path"] == "/en/latest/guides/index.html" + assert results[0]["path"] == f"/en/{version.slug}/index.html" + assert results[1]["path"] == f"/en/{version.slug}/guides/index.html" class TestDocumentSearch(BaseTestDocumentSearch):