Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
******************

Expand Down
2 changes: 1 addition & 1 deletion learning_paths/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Learning Paths plugin.
"""

__version__ = "0.4.0"
__version__ = "0.5.0"
2 changes: 1 addition & 1 deletion learning_paths/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class LearningPathStepInline(admin.TabularInline):

model = LearningPathStep
form = LearningPathStepForm
fields = ("course_key",)
fields = ("course_key", "order")


class AcquiredSkillInline(admin.TabularInline):
Expand Down
7 changes: 7 additions & 0 deletions learning_paths/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
59 changes: 58 additions & 1 deletion learning_paths/api/v1/tests/test_serializers.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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():
"""
Expand Down Expand Up @@ -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):
Expand Down
31 changes: 31 additions & 0 deletions learning_paths/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
2 changes: 2 additions & 0 deletions learning_paths/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions learning_paths/migrations/0016_alter_learningpathstep_options.py
Original file line number Diff line number Diff line change
@@ -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")},
),
]
2 changes: 2 additions & 0 deletions learning_paths/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions learning_paths/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from .factories import (
LearningPathEnrollmentAllowedFactory,
LearningPathEnrollmentAuditFactory,
LearningPathStepFactory,
)


Expand Down Expand Up @@ -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."""
Expand Down
Loading