Skip to content

Commit 5a4e901

Browse files
committed
squash!: Apply suggestions and improve testing
1 parent 5a91f85 commit 5a4e901

4 files changed

Lines changed: 109 additions & 57 deletions

File tree

openedx_authz/rest_api/v1/views.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,11 @@ def _get_courses_queryset(
675675
if allowed_ids is not None or allowed_orgs is not None:
676676
org_filter = Q(org__in=allowed_orgs) if allowed_orgs else Q()
677677
id_filter = Q(id__in=allowed_ids) if allowed_ids else Q()
678-
qs = qs.filter(org_filter | id_filter)
678+
combined_filter = org_filter | id_filter
679+
if not combined_filter:
680+
qs = qs.none()
681+
else:
682+
qs = qs.filter(combined_filter)
679683
if search:
680684
qs = qs.filter(display_name__icontains=search)
681685
return qs.annotate(
@@ -716,11 +720,16 @@ def _get_libraries_queryset(
716720
).values("scope_id", "display_name_col", "org_name", "scope_type")
717721

718722
def _build_queryset(self, courses_qs: QuerySet | None, libraries_qs: QuerySet | None) -> QuerySet:
719-
"""Union the provided querysets and sort by org. If only one is provided, return it sorted directly."""
723+
"""Union the provided querysets and sort deterministically.
724+
725+
Orders by org_name first (satisfying the 'ordered by org' requirement), then by
726+
scope_type, display_name_col, and scope_id as tiebreakers to ensure stable pagination.
727+
"""
728+
ordering = ("org_name", "scope_type", "display_name_col", "scope_id")
720729
if courses_qs is not None and libraries_qs is not None:
721-
return courses_qs.union(libraries_qs).order_by("org_name")
730+
return courses_qs.union(libraries_qs).order_by(*ordering)
722731
qs = courses_qs if courses_qs is not None else libraries_qs
723-
return qs.order_by("org_name")
732+
return qs.order_by(*ordering)
724733

725734
def get_queryset(self) -> QuerySet:
726735
"""Return scopes ordered by org, filtered by the user's permissions."""

openedx_authz/tests/rest_api/test_views.py

Lines changed: 50 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,11 @@
55
including permission validation, user-role management, and role listing capabilities.
66
"""
77

8-
import operator
9-
from functools import reduce
108
from unittest.mock import patch
119
from urllib.parse import urlencode
1210

1311
from ddt import data, ddt, unpack
1412
from django.contrib.auth import get_user_model
15-
from django.db.models import CharField, Q, Value
16-
from django.db.models.functions import Cast
1713
from django.urls import reverse
1814
from organizations.models import Organization
1915
from rest_framework import status
@@ -31,6 +27,7 @@
3127
from openedx_authz.rest_api.v1 import views
3228
from openedx_authz.rest_api.v1.permissions import DynamicScopePermission
3329
from openedx_authz.tests.api.test_roles import BaseRolesTestCase
30+
from openedx_authz.tests.stubs.models import LearningPackage
3431

3532
ContentLibrary = get_content_library_model()
3633
CourseOverview = get_course_overview_model()
@@ -924,14 +921,24 @@ def setUpTestData(cls):
924921
id=cls.COURSE_ORG2, defaults={"org": "Org2", "display_name": "Course Org2"}
925922
)
926923

924+
lp1, _ = LearningPackage.objects.get_or_create(title="Library Org1")
925+
lp2, _ = LearningPackage.objects.get_or_create(title="Library Org2")
926+
lp3, _ = LearningPackage.objects.get_or_create(title="Library Org3")
927+
927928
ContentLibrary.objects.get_or_create(
928-
slug="LIB1", org=org1, defaults={"locator": "lib:Org1:LIB1", "title": "Library Org1"}
929+
slug="LIB1",
930+
org=org1,
931+
defaults={"locator": "lib:Org1:LIB1", "title": "Library Org1", "learning_package": lp1},
929932
)
930933
ContentLibrary.objects.get_or_create(
931-
slug="LIB2", org=org2, defaults={"locator": "lib:Org2:LIB2", "title": "Library Org2"}
934+
slug="LIB2",
935+
org=org2,
936+
defaults={"locator": "lib:Org2:LIB2", "title": "Library Org2", "learning_package": lp2},
932937
)
933938
ContentLibrary.objects.get_or_create(
934-
slug="LIB3", org=org3, defaults={"locator": "lib:Org3:LIB3", "title": "Library Org3"}
939+
slug="LIB3",
940+
org=org3,
941+
defaults={"locator": "lib:Org3:LIB3", "title": "Library Org3", "learning_package": lp3},
935942
)
936943

937944
def setUp(self):
@@ -966,38 +973,6 @@ def setUp(self):
966973
self.build_qs_patcher.start()
967974
self.addCleanup(self.build_qs_patcher.stop)
968975

969-
# The stub ContentLibrary uses 'title' directly instead of 'learning_package__title'.
970-
# Patch _get_libraries_queryset to use the stub-compatible field, aliased as 'display_name'
971-
# to match the column name the union and serializer expect.
972-
def stub_get_libraries_queryset(_, allowed_pairs=None, allowed_orgs=None, search=""): # pylint: disable=unused-argument
973-
qs = ContentLibrary.objects
974-
if allowed_pairs is not None or allowed_orgs is not None:
975-
org_filter = Q(org__short_name__in=allowed_orgs) if allowed_orgs else Q()
976-
pair_filter = (
977-
reduce(operator.or_, (Q(org__short_name=org, slug=slug) for org, slug in allowed_pairs))
978-
if allowed_pairs
979-
else Q()
980-
)
981-
combined = org_filter | pair_filter
982-
if not combined:
983-
qs = qs.none()
984-
else:
985-
qs = qs.filter(combined)
986-
return qs.annotate(
987-
scope_id=Cast("slug", output_field=CharField()),
988-
display_name_col=Cast("title", output_field=CharField()),
989-
org_name=Cast("org__short_name", output_field=CharField()),
990-
scope_type=Value("library", output_field=CharField()),
991-
).values("scope_id", "display_name_col", "org_name", "scope_type")
992-
993-
self.libraries_qs_patcher = patch.object(
994-
views.AdminConsoleScopesAPIView,
995-
"_get_libraries_queryset",
996-
stub_get_libraries_queryset,
997-
)
998-
self.libraries_qs_patcher.start()
999-
self.addCleanup(self.libraries_qs_patcher.stop)
1000-
1001976
# ------------------------------------------------------------------ #
1002977
# Authentication #
1003978
# ------------------------------------------------------------------ #
@@ -1175,11 +1150,9 @@ def test_view_permission_filters_courses_for_non_staff(self):
11751150
user = User.objects.get(username="regular_9")
11761151
self.client.force_authenticate(user=user)
11771152
self.build_qs_patcher.stop()
1178-
self.libraries_qs_patcher.stop()
11791153

11801154
response = self.client.get(self.url, {"type": "course"})
11811155

1182-
self.libraries_qs_patcher.start()
11831156
self.build_qs_patcher.start()
11841157
self.assertEqual(response.status_code, status.HTTP_200_OK)
11851158
external_keys = [item["external_key"] for item in response.data["results"]]
@@ -1210,21 +1183,13 @@ def test_library_display_name_populated_in_standalone_path(self):
12101183
Regression test: without aliasing learning_package__title as display_name,
12111184
the standalone library queryset returns 'title' as the key and the serializer
12121185
silently produces empty strings since it only reads 'display_name'.
1213-
1214-
Skipped when the stub ContentLibrary model is in use (no learning_package relation).
12151186
"""
1216-
if not hasattr(ContentLibrary, "learning_package"):
1217-
self.skipTest("Stub ContentLibrary has no learning_package relation")
1218-
12191187
user = User.objects.get(username="regular_1")
12201188
self.client.force_authenticate(user=user)
1221-
# Stop both patchers so the real _get_libraries_queryset runs without union.
12221189
self.build_qs_patcher.stop()
1223-
self.libraries_qs_patcher.stop()
12241190

12251191
response = self.client.get(self.url, {"type": "library"})
12261192

1227-
self.libraries_qs_patcher.start()
12281193
self.build_qs_patcher.start()
12291194
self.assertEqual(response.status_code, status.HTTP_200_OK)
12301195
self.assertGreater(response.data["count"], 0)
@@ -1241,11 +1206,9 @@ def test_manage_permission_filters_courses_for_non_staff(self):
12411206
user = User.objects.get(username="regular_10")
12421207
self.client.force_authenticate(user=user)
12431208
self.build_qs_patcher.stop()
1244-
self.libraries_qs_patcher.stop()
12451209

12461210
response = self.client.get(self.url, {"type": "course", "management_permission_only": "true"})
12471211

1248-
self.libraries_qs_patcher.start()
12491212
self.build_qs_patcher.start()
12501213
self.assertEqual(response.status_code, status.HTTP_200_OK)
12511214
external_keys = [item["external_key"] for item in response.data["results"]]
@@ -1285,6 +1248,42 @@ def test_empty_allowed_library_pairs_returns_no_libraries(self):
12851248
self.assertEqual(response.status_code, status.HTTP_200_OK)
12861249
self.assertEqual(response.data["count"], 0)
12871250

1251+
def test_empty_allowed_course_ids_returns_no_courses(self):
1252+
"""When a non-staff user has no allowed courses, no courses are returned.
1253+
1254+
Regression test: an empty allowed_ids/allowed_orgs set must not bypass the filter
1255+
and return all courses (empty Q() | empty Q() was a no-op).
1256+
"""
1257+
# regular_1 has only library permissions, no course permissions.
1258+
user = User.objects.get(username="regular_1")
1259+
self.client.force_authenticate(user=user)
1260+
self.build_qs_patcher.stop()
1261+
1262+
response = self.client.get(self.url, {"type": "course"})
1263+
1264+
self.build_qs_patcher.start()
1265+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1266+
self.assertEqual(response.data["count"], 0)
1267+
1268+
def test_library_only_user_sees_no_courses_in_mixed_listing(self):
1269+
"""A user with only library permissions sees no courses in the default mixed listing.
1270+
1271+
Regression test: without the empty-set guard, a user with library access but no
1272+
course permissions would see all courses in the combined results.
1273+
"""
1274+
# regular_1 has only library permissions, no course permissions.
1275+
user = User.objects.get(username="regular_1")
1276+
self.client.force_authenticate(user=user)
1277+
self.build_qs_patcher.stop()
1278+
1279+
response = self.client.get(self.url)
1280+
1281+
self.build_qs_patcher.start()
1282+
self.assertEqual(response.status_code, status.HTTP_200_OK)
1283+
scope_types = {item["external_key"].split(":")[0] for item in response.data["results"]}
1284+
self.assertNotIn("course-v1", scope_types)
1285+
self.assertIn("lib", scope_types)
1286+
12881287
def test_org_glob_scope_returns_all_org_libraries(self):
12891288
"""A user with an org-level glob permission (lib:ORG:*) sees all libraries in that org."""
12901289
user = User.objects.get(username="regular_1")
@@ -1310,7 +1309,6 @@ def test_org_glob_scope_returns_all_org_courses(self):
13101309
user = User.objects.get(username="regular_9")
13111310
self.client.force_authenticate(user=user)
13121311
self.build_qs_patcher.stop()
1313-
self.libraries_qs_patcher.stop()
13141312

13151313
# Simulate get_scopes_for_user_and_permission returning an org-level glob.
13161314
glob_scope = OrgCourseOverviewGlobData(external_key="course-v1:Org1+*")
@@ -1320,7 +1318,6 @@ def test_org_glob_scope_returns_all_org_courses(self):
13201318
):
13211319
response = self.client.get(self.url, {"type": "course"})
13221320

1323-
self.libraries_qs_patcher.start()
13241321
self.build_qs_patcher.start()
13251322
self.assertEqual(response.status_code, status.HTTP_200_OK)
13261323
external_keys = [item["external_key"] for item in response.data["results"]]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import django.db.models.deletion
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
dependencies = [
7+
("stubs", "0001_initial"),
8+
]
9+
10+
operations = [
11+
migrations.CreateModel(
12+
name="LearningPackage",
13+
fields=[
14+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
15+
("title", models.CharField(max_length=255)),
16+
],
17+
),
18+
migrations.AddField(
19+
model_name="contentlibrary",
20+
name="learning_package",
21+
field=models.OneToOneField(
22+
blank=True,
23+
null=True,
24+
on_delete=django.db.models.deletion.CASCADE,
25+
to="stubs.learningpackage",
26+
),
27+
),
28+
]

openedx_authz/tests/stubs/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ def get_by_key(self, library_key):
3535
return obj
3636

3737

38+
class LearningPackage(models.Model):
39+
"""Stub model representing a learning package for testing purposes.
40+
41+
.. no_pii:
42+
"""
43+
44+
title = models.CharField(max_length=255)
45+
46+
def __str__(self):
47+
return self.title
48+
49+
3850
class ContentLibrary(models.Model):
3951
"""Stub model representing a content library for testing purposes.
4052
@@ -45,10 +57,16 @@ class ContentLibrary(models.Model):
4557
title = models.CharField(max_length=255, blank=True, null=True)
4658
slug = models.SlugField(allow_unicode=True)
4759
org = models.ForeignKey(Organization, on_delete=models.PROTECT, null=True)
60+
learning_package = models.OneToOneField(LearningPackage, on_delete=models.CASCADE, null=True, blank=True)
4861
created_at = models.DateTimeField(auto_now_add=True)
4962

5063
objects = ContentLibraryManager()
5164

65+
@property
66+
def library_key(self):
67+
"""Get the LibraryLocatorV2 opaque key for this library."""
68+
return LibraryLocatorV2(org=self.org.short_name, slug=self.slug)
69+
5270
def __str__(self):
5371
return str(self.locator)
5472

0 commit comments

Comments
 (0)