Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions readthedocs/api/v3/tests/test_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions readthedocs/api/v3/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions readthedocs/builds/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]},
),
]
2 changes: 1 addition & 1 deletion readthedocs/builds/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions readthedocs/builds/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions readthedocs/oauth/tests/test_githubapp_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion readthedocs/projects/templatetags/projects_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
4 changes: 2 additions & 2 deletions readthedocs/proxito/tests/test_full.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions readthedocs/search/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
36 changes: 18 additions & 18 deletions readthedocs/search/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand Down
Loading