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."""