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
5 changes: 5 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Release Notes
=============

Version 1.141.4
---------------

- Duplicate programs in api results (#3375)

Version 1.141.3 (Released March 11, 2026)
---------------

Expand Down
14 changes: 11 additions & 3 deletions courses/views/v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,22 @@ def __init__(self, *args, **kwargs):

@property
def qs(self):
"""If the request isn't explicitly filtering on org_id or contract_id, exclude contracted courses."""
"""Return the filtered queryset for Programs.

- Always de-duplicate results to avoid duplicate Programs when joins
(e.g., contract memberships) introduce multiple rows per Program.
- If the request isn't explicitly filtering on org_id or contract_id,
exclude B2B-only programs by default.
"""

base_qs = super().qs.distinct()

if "org_id" not in getattr(
self.request, "GET", {}
) and "contract_id" not in getattr(self.request, "GET", {}):
return super().qs.filter(b2b_only=False)
return base_qs.filter(b2b_only=False)

return super().qs
return base_qs

def filter_by_org_id(self, queryset, _, org_id):
"""Filter according to org_id. If the user is in org_id, return only related programs."""
Expand Down
46 changes: 46 additions & 0 deletions courses/views/v2/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,52 @@ def test_filter_by_contract_id_unauthenticated_user(
assert filtered.count() == 2


@pytest.mark.django_db
def test_filter_programs_by_org_and_contract_no_duplicates(
contract_ready_course, mock_course_run_clone
):
"""Filtering by both org_id and contract_id should not return duplicate programs."""

org = OrganizationPageFactory()
user = UserFactory()
user.b2b_organizations.add(org)

# Two active contracts for the same organization
contract_primary = ContractPageFactory(active=True, organization=org)
contract_other = ContractPageFactory(active=True, organization=org)

# User only has access to the primary contract
user.b2b_contracts.add(contract_primary)

# Program shared by both contracts
program = ProgramFactory()
(course, _) = contract_ready_course
program.add_requirement(course)
program.refresh_from_db()

contract_primary.add_program_courses(program)
contract_other.add_program_courses(program)

request = Request(
RequestFactory().get(
"v2:programs_api-list",
{"org_id": org.id, "contract_id": contract_primary.id},
)
)
request.user = user

filterset = ProgramFilterSet(
data={"org_id": org.id, "contract_id": contract_primary.id},
queryset=Program.objects.all(),
request=request,
)

filtered = list(filterset.qs)
# The shared program should only appear once
assert program in filtered
assert len(filtered) == 1


@pytest.mark.django_db
def test_filter_courses_with_contract_id_authenticated_user(
mocker, contract_ready_course, mock_course_run_clone
Expand Down
2 changes: 1 addition & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from main.sentry import init_sentry
from openapi.settings_spectacular import open_spectacular_settings

VERSION = "1.141.3"
VERSION = "1.141.4"

log = logging.getLogger()

Expand Down
Loading