Skip to content

Addons: sorting algorithm for versions customizable on flyout #11069

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from 18 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
529 changes: 272 additions & 257 deletions readthedocs/projects/constants.py

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions readthedocs/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from readthedocs.invitations.models import Invitation
from readthedocs.oauth.models import RemoteRepository
from readthedocs.organizations.models import Team
from readthedocs.projects.constants import ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN
from readthedocs.projects.models import (
AddonsConfig,
Domain,
Expand Down Expand Up @@ -575,6 +576,9 @@ class Meta:
"doc_diff_enabled",
"external_version_warning_enabled",
"flyout_enabled",
"flyout_sorting",
"flyout_sorting_latest_stable_at_beginning",
"flyout_sorting_custom_pattern",
"hotkeys_enabled",
"search_enabled",
"stable_latest_version_warning_enabled",
Expand All @@ -599,6 +603,18 @@ def __init__(self, *args, **kwargs):
kwargs["instance"] = addons
super().__init__(*args, **kwargs)

def clean(self):
if (
self.cleaned_data["flyout_sorting"] == ADDONS_FLYOUT_SORTING_CUSTOM_PATTERN
and not self.cleaned_data["flyout_sorting_custom_pattern"]
):
raise forms.ValidationError(
_(
"The flyout sorting custom pattern is required when selecting a custom pattern."
),
)
return super().clean()

def clean_project(self):
return self.project

Expand Down
83 changes: 83 additions & 0 deletions readthedocs/projects/migrations/0118_addons_flyout_sorting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Generated by Django 4.2.10 on 2024-03-04 13:32

from django.db import migrations, models
from django_safemigrate import Safe


class Migration(migrations.Migration):
safe = Safe.before_deploy

dependencies = [
("projects", "0117_remove_old_fields"),
]

operations = [
migrations.AddField(
model_name="addonsconfig",
name="flyout_sorting",
field=models.CharField(
choices=[
("alphabetically", "Alphabetically"),
("semver-readthedocs-compatible", "SemVer (Read the Docs)"),
("python-packaging", "Python Packaging (PEP 440 and PEP 425)"),
("calver", "CalVer (YYYY.0M.0M)"),
("custom-pattern", "Define your own pattern"),
],
default="alphabetically",
max_length=64,
),
),
migrations.AddField(
model_name="addonsconfig",
name="flyout_sorting_custom_pattern",
field=models.CharField(
blank=True,
default=None,
help_text='Sorting pattern supported by BumpVer (<a href="https://github.com/mbarkhau/bumpver#pattern-examples">See examples</a>',
max_length=32,
null=True,
),
),
migrations.AddField(
model_name="addonsconfig",
name="flyout_sorting_latest_stable_at_beginning",
field=models.BooleanField(
default=True,
help_text="Show <code>latest</code> and <code>stable</code> at the beginning",
),
),
migrations.AddField(
model_name="historicaladdonsconfig",
name="flyout_sorting",
field=models.CharField(
choices=[
("alphabetically", "Alphabetically"),
("semver-readthedocs-compatible", "SemVer (Read the Docs)"),
("python-packaging", "Python Packaging (PEP 440 and PEP 425)"),
("calver", "CalVer (YYYY.0M.0M)"),
("custom-pattern", "Define your own pattern"),
],
default="alphabetically",
max_length=64,
),
),
migrations.AddField(
model_name="historicaladdonsconfig",
name="flyout_sorting_custom_pattern",
field=models.CharField(
blank=True,
default=None,
help_text='Sorting pattern supported by BumpVer (<a href="https://github.com/mbarkhau/bumpver#pattern-examples">See examples</a>',
max_length=32,
null=True,
),
),
migrations.AddField(
model_name="historicaladdonsconfig",
name="flyout_sorting_latest_stable_at_beginning",
field=models.BooleanField(
default=True,
help_text="Show <code>latest</code> and <code>stable</code> at the beginning",
),
),
]
19 changes: 19 additions & 0 deletions readthedocs/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
from readthedocs.vcs_support.backends import backend_cls

from .constants import (
ADDONS_FLYOUT_SORTING_ALPHABETICALLY,
ADDONS_FLYOUT_SORTING_CHOICES,
DOWNLOADABLE_MEDIA_TYPES,
MEDIA_TYPES,
MULTIPLE_VERSIONS_WITH_TRANSLATIONS,
Expand Down Expand Up @@ -182,6 +184,23 @@ class AddonsConfig(TimeStampedModel):

# Flyout
flyout_enabled = models.BooleanField(default=True)
flyout_sorting = models.CharField(
choices=ADDONS_FLYOUT_SORTING_CHOICES,
default=ADDONS_FLYOUT_SORTING_ALPHABETICALLY,
max_length=64,
)
flyout_sorting_custom_pattern = models.CharField(
max_length=32,
default=None,
null=True,
blank=True,
help_text="Sorting pattern supported by BumpVer "
'(<a href="https://github.com/mbarkhau/bumpver#pattern-examples">See examples</a>)',
)
flyout_sorting_latest_stable_at_beginning = models.BooleanField(
default=True,
help_text="Show <code>latest</code> and <code>stable</code> at the beginning",
)

# Hotkeys
hotkeys_enabled = models.BooleanField(default=True)
Expand Down
207 changes: 207 additions & 0 deletions readthedocs/projects/tests/test_version_handling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import django_dynamic_fixture as fixture
import pytest

from readthedocs.builds.models import Build, Version
from readthedocs.projects.models import AddonsConfig, Project
from readthedocs.projects.version_handling import (
sort_versions_calver,
sort_versions_custom_pattern,
sort_versions_python_packaging,
)


@pytest.mark.django_db(databases="__all__")
class TestVersionHandling:
@pytest.fixture(autouse=True)
def setup(self, requests_mock):
# Save the reference to query it from inside the test
self.requests_mock = requests_mock

self.project = fixture.get(Project, slug="project")
self.addons = fixture.get(AddonsConfig, project=self.project)
self.version = self.project.versions.get(slug="latest")
self.build = fixture.get(
Build,
version=self.version,
commit="a1b2c3",
)

def test_sort_versions_python_packaging(self):
slugs = [
"v1.0",
"1.1",
"invalid",
"2.5.3",
"1.1.0",
"another-invalid",
]

expected = [
# `latest` and `stable` are at the beginning
"latest",
"v1.0",
"1.1",
"1.1.0",
"2.5.3",
# Invalid versions are at the end sorted alphabetically.
"another-invalid",
"invalid",
]

for slug in slugs:
fixture.get(
Version,
slug=slug,
project=self.project,
)

sorted_versions = sort_versions_python_packaging(
self.project.versions.all(),
latest_stable_at_beginning=True,
)
assert expected == [version.slug for version in sorted_versions]

def test_sort_versions_python_packaging_latest_stable_not_at_beginning(self):
slugs = [
"v1.0",
"1.1",
"invalid",
"2.5.3",
"1.1.0",
"another-invalid",
]

expected = [
"v1.0",
"1.1",
"1.1.0",
"2.5.3",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to invert this to show the newest version first in the flyout?

Now it will display:

v1.0  1.1  1.1.0   2.5.3

but maybe what we want is:

2.5.3  1.1.0  1.1  v1.0

This is how the old flyout sorts the versions (the newest first). Example: https://docs.godotengine.org/en/stable/

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinging @astrojuanlu since you are one of the users wanting this feature.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think descending order makes more sense

Copy link
Member Author

@humitos humitos Mar 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, the result for this case using Python Packaging sorting will be:

2.5.3	1.1	1.1.0	v1.0

Then, the final result with Read the Docs special versions and invalid ones will be:

latest	stable	2.5.3	1.1	1.1.0	v1.0	another	invalid

The pattern is:

<latest if active> <stable if active> <versions matching the pattern descending> <invalid versions alphabetically ascending>

# Invalid versions are at the end sorted alphabetically.
"another-invalid",
"invalid",
"latest",
]

for slug in slugs:
fixture.get(
Version,
slug=slug,
project=self.project,
)

sorted_versions = sort_versions_python_packaging(
self.project.versions.all(),
latest_stable_at_beginning=False,
)
assert expected == [version.slug for version in sorted_versions]

def test_sort_versions_calver(self):
slugs = [
"2022.01.22",
"2023.04.22",
"2021.01.22",
"2022.05.02",
# invalid ones
"2001.16.32",
"2001.02.2",
"2001-02-27",
"1.1",
"invalid",
"2.5.3",
"1.1.0",
"another-invalid",
]

expected = [
# `latest` and `stable` are at the beginning
"latest",
"stable",
"2021.01.22",
"2022.01.22",
"2022.05.02",
"2023.04.22",
# invalid ones (alphabetically)
"1.1",
"1.1.0",
"2.5.3",
"2001-02-27",
"2001.02.2",
"2001.16.32",
"another-invalid",
"invalid",
]

for slug in slugs:
fixture.get(
Version,
slug=slug,
project=self.project,
)

fixture.get(
Version,
slug="stable",
machine=True,
project=self.project,
)

sorted_versions = sort_versions_calver(
self.project.versions.all(),
latest_stable_at_beginning=True,
)

assert expected == [version.slug for version in sorted_versions]

def test_sort_versions_custom_pattern(self):
slugs = [
"v1.0",
"v1.1",
"v2.3",
# invalid ones
"v1.1.0",
"v2.3rc1",
"invalid",
"2.5.3",
"2022.01.22",
"1.1",
"another-invalid",
]

expected = [
# `latest` and `stable` are at the beginning
"latest",
"stable",
"v1.0",
"v1.1",
"v2.3",
# invalid ones (alphabetically)
"1.1",
"2.5.3",
"2022.01.22",
"another-invalid",
"invalid",
"v1.1.0",
"v2.3rc1",
]

for slug in slugs:
fixture.get(
Version,
slug=slug,
project=self.project,
)

fixture.get(
Version,
slug="stable",
machine=True,
project=self.project,
)

sorted_versions = sort_versions_custom_pattern(
self.project.versions.all(),
raw_pattern="vMAJOR.MINOR",
latest_stable_at_beginning=True,
)

assert expected == [version.slug for version in sorted_versions]
Loading