From d125e5142ff3df5d3544eaae1ebd3ab2caab3cc4 Mon Sep 17 00:00:00 2001 From: Agrendalath Date: Tue, 11 Aug 2026 00:13:46 +0200 Subject: [PATCH] feat: allow specifying the order of Learning Path steps The "Sequential order" field is now editable in the Django admin interface, and the steps are ordered by it in the API. The steps with no explicit order are listed last, in the order of their creation. --- CHANGELOG.rst | 14 +++++ learning_paths/__init__.py | 2 +- learning_paths/admin.py | 2 +- learning_paths/api/v1/serializers.py | 7 +++ .../api/v1/tests/test_serializers.py | 59 ++++++++++++++++++- learning_paths/api/v1/tests/test_views.py | 31 ++++++++++ learning_paths/api/v1/views.py | 2 + .../0016_alter_learningpathstep_options.py | 17 ++++++ learning_paths/models.py | 2 + learning_paths/tests/test_models.py | 35 +++++++++++ 10 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 learning_paths/migrations/0016_alter_learningpathstep_options.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e4b8948..c9e65ec 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,20 @@ Unreleased * +0.5.0 - 2026-08-11 +****************** + +Added +===== + +* Step order in the Django admin interface. + +Changed +======= + +* Order the Learning Path steps by their sequential order in the API, with the + steps that have no explicit order at the end. + 0.4.0 - 2026-06-12 ****************** diff --git a/learning_paths/__init__.py b/learning_paths/__init__.py index efc2392..ba5924f 100644 --- a/learning_paths/__init__.py +++ b/learning_paths/__init__.py @@ -2,4 +2,4 @@ Learning Paths plugin. """ -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/learning_paths/admin.py b/learning_paths/admin.py index 86ec2d7..781a2c0 100644 --- a/learning_paths/admin.py +++ b/learning_paths/admin.py @@ -93,7 +93,7 @@ class LearningPathStepInline(admin.TabularInline): model = LearningPathStep form = LearningPathStepForm - fields = ("course_key",) + fields = ("course_key", "order") class AcquiredSkillInline(admin.TabularInline): diff --git a/learning_paths/api/v1/serializers.py b/learning_paths/api/v1/serializers.py index 580819e..10aeaa1 100644 --- a/learning_paths/api/v1/serializers.py +++ b/learning_paths/api/v1/serializers.py @@ -95,6 +95,13 @@ class LearningPathGradeSerializer(serializers.Serializer): class LearningPathStepSerializer(serializers.ModelSerializer): + """ + Serializer for a Learning Path step. + + The steps are serialized in the order defined by the ``order`` field, with + the steps that have no explicit order at the end. + """ + class Meta: model = LearningPathStep fields = ["order", "course_key", "course_dates", "weight"] diff --git a/learning_paths/api/v1/tests/test_serializers.py b/learning_paths/api/v1/tests/test_serializers.py index a9c58a4..3a6d3cb 100644 --- a/learning_paths/api/v1/tests/test_serializers.py +++ b/learning_paths/api/v1/tests/test_serializers.py @@ -1,4 +1,7 @@ -# pylint: disable=missing-module-docstring +# pylint: disable=missing-module-docstring,redefined-outer-name,unused-argument +from datetime import datetime, timezone +from unittest.mock import patch + import pytest from django.core.files.uploadedfile import SimpleUploadedFile @@ -12,9 +15,19 @@ from learning_paths.tests.factories import ( LearningPathEnrollmentFactory, LearningPathFactory, + LearningPathStepFactory, ) +@pytest.fixture +def mock_course_dates(): + """Mock course dates that are retrieved from edx-platform.""" + start_date = datetime(2024, 1, 1, tzinfo=timezone.utc) + end_date = datetime(2025, 1, 1, tzinfo=timezone.utc) + with patch("learning_paths.models.get_course_dates", return_value=(start_date, end_date)) as mock: + yield mock + + @pytest.mark.django_db def test_learning_path_as_program_serializer( temp_media, @@ -47,6 +60,24 @@ def test_learning_path_as_program_serializer( assert dict(serializer.data) == expected +@pytest.mark.django_db +def test_learning_path_as_program_serializer_course_codes_order(): + """ + Tests that LearningPathAsProgram serializes the course codes in the sequential order of the steps. + """ + learning_path = LearningPathFactory() + LearningPathStepFactory(learning_path=learning_path, order=None, course_key="course-v1:edX+UnorderedX+Run") + LearningPathStepFactory(learning_path=learning_path, order=2, course_key="course-v1:edX+SecondX+Run") + LearningPathStepFactory(learning_path=learning_path, order=1, course_key="course-v1:edX+FirstX+Run") + + serializer = LearningPathAsProgramSerializer(learning_path) + assert [course_code["key"] for course_code in serializer.data["course_codes"]] == [ + "FirstX", + "SecondX", + "UnorderedX", + ] + + @pytest.mark.django_db def test_learning_path_progress_serializer(): """ @@ -109,6 +140,32 @@ def test_list_serializer(): assert dict(serializer.data) == expected +@pytest.mark.django_db +@pytest.mark.parametrize( + "serializer_class", + [LearningPathListSerializer, LearningPathDetailSerializer], + ids=["list", "detail"], +) +def test_serializer_steps_order(mock_course_dates, serializer_class): + """ + Tests that the steps are serialized in their sequential order, with the unordered ones at the end. + """ + learning_path = LearningPathFactory() + LearningPathStepFactory(learning_path=learning_path, order=None, course_key="course-v1:edX+DemoX+Unordered") + LearningPathStepFactory(learning_path=learning_path, order=2, course_key="course-v1:edX+DemoX+Second") + LearningPathStepFactory(learning_path=learning_path, order=1, course_key="course-v1:edX+DemoX+First") + + serializer = serializer_class(learning_path) + steps = serializer.data["steps"] + + assert [step["order"] for step in steps] == [1, 2, None] + assert [step["course_key"] for step in steps] == [ + "course-v1:edX+DemoX+First", + "course-v1:edX+DemoX+Second", + "course-v1:edX+DemoX+Unordered", + ] + + @pytest.mark.django_db @pytest.mark.parametrize("is_enrolled", [True, False], ids=["enrolled", "not_enrolled"]) def test_list_serializer_enrollment(user, learning_path, is_enrolled): diff --git a/learning_paths/api/v1/tests/test_views.py b/learning_paths/api/v1/tests/test_views.py index f6b42a9..3115e4f 100644 --- a/learning_paths/api/v1/tests/test_views.py +++ b/learning_paths/api/v1/tests/test_views.py @@ -238,6 +238,37 @@ def test_learning_path_retrieve(self, authenticated_client, learning_paths_with_ assert "course_dates" in first_step assert "weight" in first_step + @pytest.mark.parametrize("endpoint", ["learning-path-list", "learning-path-detail"], ids=["list", "detail"]) + def test_learning_path_steps_are_ordered(self, authenticated_client, learning_path, endpoint): + """Test that the steps are returned in their sequential order, with the unordered ones at the end.""" + LearningPathStepFactory.create( + learning_path=learning_path, + order=None, + course_key="course-v1:edX+DemoX+Unordered", + ) + LearningPathStepFactory.create( + learning_path=learning_path, + order=2, + course_key="course-v1:edX+DemoX+Second", + ) + LearningPathStepFactory.create( + learning_path=learning_path, + order=1, + course_key="course-v1:edX+DemoX+First", + ) + + args = [] if endpoint == "learning-path-list" else [learning_path.key] + response = authenticated_client.get(reverse(endpoint, args=args)) + + assert response.status_code == status.HTTP_200_OK + data = response.data[0] if endpoint == "learning-path-list" else response.data + assert [step["order"] for step in data["steps"]] == [1, 2, None] + assert [step["course_key"] for step in data["steps"]] == [ + "course-v1:edX+DemoX+First", + "course-v1:edX+DemoX+Second", + "course-v1:edX+DemoX+Unordered", + ] + def test_invalid_learning_path_key_returns_404(self, authenticated_client): """Test that an invalid learning path key format returns a 404 response.""" url = reverse("learning-path-detail", args=["invalid-key-format"]) diff --git a/learning_paths/api/v1/views.py b/learning_paths/api/v1/views.py index a66d20c..fa9dc7a 100644 --- a/learning_paths/api/v1/views.py +++ b/learning_paths/api/v1/views.py @@ -152,6 +152,8 @@ class LearningPathViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): """ Get all learning paths and prefetch the related data. + + The steps are prefetched in their sequential order, with the steps that have no explicit order at the end. """ user = self.request.user queryset = LearningPath.objects.get_paths_visible_to_user(user).prefetch_related( diff --git a/learning_paths/migrations/0016_alter_learningpathstep_options.py b/learning_paths/migrations/0016_alter_learningpathstep_options.py new file mode 100644 index 0000000..f126edc --- /dev/null +++ b/learning_paths/migrations/0016_alter_learningpathstep_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.23 on 2026-08-10 21:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("learning_paths", "0015_make_skill_level_optional"), + ] + + operations = [ + migrations.AlterModelOptions( + name="learningpathstep", + options={"ordering": (models.OrderBy(models.F("order"), nulls_last=True), "id")}, + ), + ] diff --git a/learning_paths/models.py b/learning_paths/models.py index d644f86..de33a37 100644 --- a/learning_paths/models.py +++ b/learning_paths/models.py @@ -195,6 +195,8 @@ class Meta: """Model options.""" unique_together = ("learning_path", "course_key") + # Steps without an explicit order are listed last, in the order of their creation. + ordering = (models.F("order").asc(nulls_last=True), "id") course_key = CourseKeyField(max_length=255) learning_path = models.ForeignKey(LearningPath, related_name="steps", on_delete=models.CASCADE) diff --git a/learning_paths/tests/test_models.py b/learning_paths/tests/test_models.py index 6a789bd..c9a307c 100644 --- a/learning_paths/tests/test_models.py +++ b/learning_paths/tests/test_models.py @@ -22,6 +22,7 @@ from .factories import ( LearningPathEnrollmentAllowedFactory, LearningPathEnrollmentAuditFactory, + LearningPathStepFactory, ) @@ -146,6 +147,40 @@ def test_grading_criteria_auto_creation(self, learning_path): assert criteria.required_grade == 0.75 +@pytest.mark.django_db +class TestLearningPathStep: + """Tests for the LearningPathStep model.""" + + def test_string_representation(self, learning_path): + """Test the string representation.""" + step = LearningPathStepFactory(learning_path=learning_path, order=2) + assert str(step) == f"2: {step.course_key}" + + def test_steps_are_ordered_by_the_order_field(self, learning_path): + """Test that the steps are returned in their sequential order, regardless of their creation order.""" + third = LearningPathStepFactory(learning_path=learning_path, order=3, course_key="course-v1:edX+DemoX+Third") + first = LearningPathStepFactory(learning_path=learning_path, order=1, course_key="course-v1:edX+DemoX+First") + second = LearningPathStepFactory(learning_path=learning_path, order=2, course_key="course-v1:edX+DemoX+Second") + + assert list(learning_path.steps.all()) == [first, second, third] + + def test_steps_without_order_are_listed_last(self, learning_path): + """Test that the steps with no explicit order are listed last, in the order of their creation.""" + first_unordered = LearningPathStepFactory( + learning_path=learning_path, + order=None, + course_key="course-v1:edX+DemoX+FirstUnordered", + ) + second_unordered = LearningPathStepFactory( + learning_path=learning_path, + order=None, + course_key="course-v1:edX+DemoX+SecondUnordered", + ) + ordered = LearningPathStepFactory(learning_path=learning_path, order=1, course_key="course-v1:edX+DemoX+First") + + assert list(learning_path.steps.all()) == [ordered, first_unordered, second_unordered] + + @pytest.mark.django_db class TestLearningPathEnrollmentAllowed: """Tests for the LearningPathEnrollmentAllowed model."""