From b319741a459aace8395b88c20088c068d4c2bd92 Mon Sep 17 00:00:00 2001 From: Maria Fernanda Magallanes Zubillaga Date: Fri, 10 Jul 2026 18:15:47 -0500 Subject: [PATCH 1/7] feat: add waffle flag states rest api and util --- openedx_authz/rest_api/v1/urls.py | 1 + openedx_authz/rest_api/v1/views.py | 32 +++++++++++++++++- openedx_authz/utils.py | 54 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/openedx_authz/rest_api/v1/urls.py b/openedx_authz/rest_api/v1/urls.py index 6c7def28..e31e5d79 100644 --- a/openedx_authz/rest_api/v1/urls.py +++ b/openedx_authz/rest_api/v1/urls.py @@ -20,4 +20,5 @@ ), path("assignments/", views.AssignmentsAPIView.as_view(), name="assignment-list"), path("scopes/", views.ScopesAPIView.as_view(), name="scope-list"), + path("waffle-flag-states/", views.WaffleFlagStatesAPIView.as_view(), name="waffle-flag-states"), ] diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 96686338..390dda49 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -77,7 +77,7 @@ UserValidationAPIViewResponseSerializer, UserValidationAPIViewSerializer, ) -from openedx_authz.utils import get_user_by_username_or_email +from openedx_authz.utils import get_user_by_username_or_email, get_waffle_flag_states logger = logging.getLogger(__name__) @@ -1374,3 +1374,33 @@ def get(self, request: HttpRequest) -> Response: paginator = self.pagination_class() paginated_response_data = paginator.paginate_queryset(assignments, request) return paginator.get_paginated_response(paginated_response_data) + + +@view_auth_classes() +class WaffleFlagStatesAPIView(APIView): + """ + Simple API view that returns the waffle flag states from utils.get_waffle_flag_states. + + **Endpoints** + + - GET: Retrieve the enablement state of the course-authoring waffle flag across different scopes. + + **Response Format** + + * 'global' (bool): True if the global waffle flag is enabled. + * 'org' (bool): True if at least one organization-level override is enabled. + * 'course' (bool): True if at least one course-level override is enabled. + + **Example Request** + + GET /api/authz/v1/waffle-flag-states/ + """ + + def get(self, request: HttpRequest) -> Response: + """Retrieve the enablement state of the course-authoring waffle flag across different scopes.""" + try: + data = get_waffle_flag_states() + return Response(data, status=status.HTTP_200_OK) + except Exception as e: # pylint: disable=broad-exception-caught + logger.exception("Error getting waffle flag states: %s", e) + return Response({"message": "error"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/openedx_authz/utils.py b/openedx_authz/utils.py index 12ec3c47..7d5ba16a 100644 --- a/openedx_authz/utils.py +++ b/openedx_authz/utils.py @@ -4,6 +4,22 @@ from django.db.models import Q from edx_django_utils.cache import RequestCache +try: + from openedx.core.djangoapps.waffle_utils.models import ( + WaffleFlagCourseOverrideModel, + WaffleFlagOrgOverrideModel, + ) + from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG +except ImportError: + WaffleFlagCourseOverrideModel = None + WaffleFlagOrgOverrideModel = None + AUTHZ_COURSE_AUTHORING_FLAG = None + +from waffle.models import Flag + +# Match handlers.py semantics: an override forces ON when override_choice == "on" +WAFFLE_OVERRIDE_FORCE_ON = "on" + User = get_user_model() _STAFF_SUPERUSER_CACHE_NAMESPACE = "rbac_is_staff_or_superuser" @@ -50,3 +66,41 @@ def get_user_by_username_or_email(username_or_email: str) -> User: if hasattr(user, "userretirementrequest"): raise User.DoesNotExist return user + + +def get_waffle_flag_states() -> dict: + """ + Retrieve the enablement state of the course-authoring waffle flag across different scopes. + + Returns: + dict: A dictionary mapping scopes to their activation status: + * 'global' (bool): True if the global waffle flag is enabled. + * 'org' (bool): True if at least one organization-level override is enabled. + * 'course' (bool): True if at least one course-level override is enabled. + """ + # Global flag (falls back False if toggle not available) + global_enabled = False + if AUTHZ_COURSE_AUTHORING_FLAG is not None: + gf = Flag.objects.filter(name=AUTHZ_COURSE_AUTHORING_FLAG.name).first() + global_enabled = bool(gf and gf.everyone) + + org_active = False + course_active = False + + # Check if any org-level override currently forces the flag on (no org filter) + if WaffleFlagOrgOverrideModel is not None and AUTHZ_COURSE_AUTHORING_FLAG is not None: + org_active = WaffleFlagOrgOverrideModel.objects.current_set().filter( + waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG.name, + enabled=True, + override_choice=WAFFLE_OVERRIDE_FORCE_ON, + ).exists() + + # Check if any course-level override currently forces the flag on (no course filter) + if WaffleFlagCourseOverrideModel is not None and AUTHZ_COURSE_AUTHORING_FLAG is not None: + course_active = WaffleFlagCourseOverrideModel.objects.current_set().filter( + waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG.name, + enabled=True, + override_choice=WAFFLE_OVERRIDE_FORCE_ON, + ).exists() + + return {"global": global_enabled, "org": org_active, "course": course_active} From 0b61f7a36566474b5019f93fe359173e792889c1 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 14 Jul 2026 15:35:40 +0200 Subject: [PATCH 2/7] fix: address PR #358 review comments on get_waffle_flag_states - get_waffle_flag_states used a manual Flag query for the global tier instead of enable_authz_course_authoring, and returned flat booleans for org/course overrides instead of the actual affected orgs/courses, split by whether the override forces the flag on or off. - Adds test coverage for get_waffle_flag_states and WaffleFlagStatesAPIView, and an ADR documenting why this endpoint (issue #358) supersedes PR #361's approach of enforcing the flag cascade inside release-blocking endpoints. Co-Authored-By: Claude Sonnet 5 --- ...thoring-waffle-flag-state-via-rest-api.rst | 71 +++++++++++ openedx_authz/rest_api/v1/views.py | 7 +- openedx_authz/tests/rest_api/test_views.py | 51 ++++++++ openedx_authz/tests/test_utils.py | 111 ++++++++++++++++++ openedx_authz/utils.py | 72 +++++++----- 5 files changed, 279 insertions(+), 33 deletions(-) create mode 100644 docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst diff --git a/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst b/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst new file mode 100644 index 00000000..bebd228f --- /dev/null +++ b/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst @@ -0,0 +1,71 @@ +0015: Expose Course-Authoring Waffle Flag State via REST API +############################################################## + +Status +****** + +**Draft** + +Context +******* + +``authz.enable_course_authoring`` is a three-tier flag (`ADR 0010`_), where a course override wins over an org override, which in turn wins over the platform default. + +`Issue #340`_ and `issue #341`_ report that the admin-console MFE keeps showing Authoring-related roles, scopes, and role assignments even when this flag is off, since nothing currently checks it. Both issues ask for a simpler rule than the full cascade. The Authoring UI should show if the flag is on at any level (platform, org, or course), and hide only if it's off at every level. `A review comment on frontend-app-admin-console#176`_ lays out the fuller course/org/platform truth table this problem could ideally follow. + +`PR #361`_ attempted to enforce that full truth table directly inside ``PermissionValidationMeView`` and other REST API endpoints, checking the flag per scope on every request. Per `PR #361's own comment thread`_, those endpoints are release-blocking for Verawood, so baking precise per-scope flag logic into them risked correctness and performance on critical paths without enough test coverage across the framework to be confident in time for the release. That approach was reverted, and the team pivoted to `issue #358`_ instead, exposing the flag's raw state through a dedicated endpoint and letting the admin-console MFE apply the simpler #340/#341 rule itself, deferring precise per-scope filtering to a later cycle. + +Neither edx-toggles nor edx-platform expose a public API for this. ``/api/toggles/v0/state/`` (`edx_toggles source`_) only reports the global waffle flag's ``everyone`` value, with no awareness of course or org overrides. ``WaffleFlagOrgOverrideModel.override_value(name, key)`` and its course-level counterpart (`waffle_utils models source`_) each answer for one specific org or course, not "which orgs/courses have an override." + +Decision +******** + +1. Add ``GET /api/authz/v1/waffle-flag-states/``, backed by ``openedx_authz.utils.get_waffle_flag_states()``, returning the flag's global state plus every org and course that currently has an active override, split into 'on' and 'off' lists. +2. The admin-console MFE decides what to show using this response, applying the #340/#341 rule for this release. +3. This supersedes PR #361's approach of enforcing the full cascade inside REST API endpoints themselves, for this release. PR #361's per-scope logic (``is_scope_visible``/``has_visible_scope``) stays documented on that branch for a future cycle. +4. Making the REST API endpoints themselves aware of the flag is still an open problem, and needs to be addressed on its own. Given the release timeline and the risk PR #361 surfaced, the team chose this more straightforward solution for now. + +Consequences +************ + +#. **Release-blocking endpoints stay untouched.** ``PermissionValidationMeView`` and the other endpoints named in PR #361 keep their existing behavior. This endpoint is additive, isolated, low-risk. +#. **One place answers "what's the flag's state right now."** ``get_waffle_flag_states()`` centralizes the lookup, reusing ``enable_authz_course_authoring()`` for the global tier and querying ``WaffleFlagOrgOverrideModel``/``WaffleFlagCourseOverrideModel`` directly for the org/course tiers, since no public API answers "which orgs/courses have an override." +#. **The MFE bears the filtering complexity.** Applying the #340/#341 "any tier on" rule, and any future precise per-course/per-org filtering, is MFE-side logic from here on. +#. **These override queries scan the whole table, unfiltered by any specific org/course.** For instances with many overrides, this is a full-table read on every call. Not a problem at current scale, but worth revisiting if usage grows (see `issue #360`_). +#. **``openedx_authz.utils`` now depends on** ``common.djangoapps.student.roles.enable_authz_course_authoring`` **and** ``openedx.core.djangoapps.waffle_utils.models``, guarded by the same standalone-import pattern already used elsewhere in this repo (``rest_api/utils.py``, ``handlers.py``). This is a temporary, direct edx-platform dependency, tracked as follow-up work under `issue #360`_ (moving the dependency direction so services depend on ``openedx_authz``). + +Rejected Alternatives +********************** + +**Enforcing the full per-scope truth table inside release-blocking REST API endpoints (PR #361)** + Correctness and performance across the whole framework weren't validated in time for a release-blocking change, per PR #361's own comment thread. The simpler #340/#341 rule doesn't need per-scope precision to ship. + +**Relying on** ``/api/toggles/v0/state/`` + This edx-toggles endpoint only reports the global flag value. It has no awareness of ``WaffleFlagOrgOverrideModel``/``WaffleFlagCourseOverrideModel``, so it cannot answer whether any org or course has an override, and it requires Django staff to call. + +References +********** + +* `ADR 0010`_ +* `Issue #340`_ +* `Issue #341`_ +* `Issue #358`_ +* `Issue #360`_ +* `PR #361`_ +* `PR #361's own comment thread`_ +* `A review comment on frontend-app-admin-console#176`_ + +.. _ADR 0010: 0010-course-authoring-flag.rst +.. _Issue #340: https://github.com/openedx/openedx-authz/issues/340 +.. _issue #340: https://github.com/openedx/openedx-authz/issues/340 +.. _Issue #341: https://github.com/openedx/openedx-authz/issues/341 +.. _issue #341: https://github.com/openedx/openedx-authz/issues/341 +.. _Issue #358: https://github.com/openedx/openedx-authz/issues/358 +.. _issue #358: https://github.com/openedx/openedx-authz/issues/358 +.. _Issue #360: https://github.com/openedx/openedx-authz/issues/360 +.. _issue #360: https://github.com/openedx/openedx-authz/issues/360 +.. _PR #361: https://github.com/openedx/openedx-authz/pull/361 +.. _PR #361's own comment thread: https://github.com/openedx/openedx-authz/pull/361#issuecomment-4967053225 +.. _A review comment on frontend-app-admin-console#176: https://github.com/openedx/frontend-app-admin-console/pull/176#issuecomment-4900922914 +.. _edx_toggles source: https://github.com/openedx/edx-toggles/blob/master/edx_toggles/toggles/state/internal/report.py +.. _waffle_utils models source: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/waffle_utils/models.py diff --git a/openedx_authz/rest_api/v1/views.py b/openedx_authz/rest_api/v1/views.py index 390dda49..eb528cd4 100644 --- a/openedx_authz/rest_api/v1/views.py +++ b/openedx_authz/rest_api/v1/views.py @@ -1388,9 +1388,10 @@ class WaffleFlagStatesAPIView(APIView): **Response Format** * 'global' (bool): True if the global waffle flag is enabled. - * 'org' (bool): True if at least one organization-level override is enabled. - * 'course' (bool): True if at least one course-level override is enabled. - + * 'org_overrides' (dict): Orgs with an organization-level override, as 'on' + (forces the flag on) and 'off' (forces the flag off) lists. + * 'course_overrides' (dict): Courses with a course-level override, split the same way. + **Example Request** GET /api/authz/v1/waffle-flag-states/ diff --git a/openedx_authz/tests/rest_api/test_views.py b/openedx_authz/tests/rest_api/test_views.py index e7632817..30bb173f 100644 --- a/openedx_authz/tests/rest_api/test_views.py +++ b/openedx_authz/tests/rest_api/test_views.py @@ -4338,3 +4338,54 @@ def test_scope_permission_vs_platform_permission(self, username, scopes, expecte response = self._put_lib(scopes, roles.LIBRARY_ADMIN.external_key) self.assertEqual(response.status_code, expected_status) + + +class TestWaffleFlagStatesAPIView(ViewTestMixin): + """Test suite for WaffleFlagStatesAPIView.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.url = reverse("openedx_authz:waffle-flag-states") + + def test_get_returns_the_waffle_flag_states(self): + """Test GET /waffle-flag-states/ with a successful lookup. + + Expected result: + - Returns 200 OK status. + - The response body is whatever get_waffle_flag_states returns, unchanged. + """ + flag_states = { + "global": True, + "org_overrides": {"on": ["Org1"], "off": []}, + "course_overrides": {"on": [], "off": ["course-v1:Org1+COURSE1+2024"]}, + } + with patch("openedx_authz.rest_api.v1.views.get_waffle_flag_states", return_value=flag_states): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, flag_states) + + def test_get_handles_an_unexpected_error(self): + """Test GET /waffle-flag-states/ when get_waffle_flag_states raises. + + Expected result: + - Returns 500 INTERNAL SERVER ERROR status with a generic error message. + """ + with patch("openedx_authz.rest_api.v1.views.get_waffle_flag_states", side_effect=Exception("boom")): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertEqual(response.data, {"message": "error"}) + + def test_get_requires_authentication(self): + """Test GET /waffle-flag-states/ without authentication. + + Expected result: + - Returns 401 UNAUTHORIZED status. + """ + self.client.force_authenticate(user=None) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/openedx_authz/tests/test_utils.py b/openedx_authz/tests/test_utils.py index e3497143..ce000397 100644 --- a/openedx_authz/tests/test_utils.py +++ b/openedx_authz/tests/test_utils.py @@ -1,5 +1,11 @@ """Test utilities for creating namespaced keys using class constants.""" +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from ddt import data, ddt, unpack +from django.test import TestCase + from openedx_authz.api.data import ( GLOBAL_SCOPE_WILDCARD, ActionData, @@ -9,6 +15,9 @@ ScopeData, UserData, ) +from openedx_authz.utils import get_waffle_flag_states + +FLAG_NAME = "authz.enable_course_authoring" def make_policy(role_key: str, action_key: str, scope_key: str, effect: str = "allow") -> list[str]: @@ -187,3 +196,105 @@ def make_wildcard_key(namespace: str) -> str: str: Wildcard pattern (e.g., 'lib^*', 'org^*', 'course^*') """ return f"{namespace}{ScopeData.SEPARATOR}{GLOBAL_SCOPE_WILDCARD}" + + +@ddt +class TestGetWaffleFlagStates(TestCase): + """Test get_waffle_flag_states, which reports the course-authoring flag's state at each tier.""" + + def _mock_override_model(self, override_rows: list): + """Build a mock override model. override_rows is a list of (key, override_choice) tuples.""" + mock_model = MagicMock() + mock_model.objects.current_set.return_value.filter.return_value.values_list.return_value = override_rows + return mock_model + + @data(True, False) + def test_global_tier_follows_the_platform_flag(self, platform_enabled: bool): + """Test get_waffle_flag_states' global key. + + Expected result: + - Matches enable_authz_course_authoring()'s platform-tier result. + """ + with patch( + "openedx_authz.utils.enable_authz_course_authoring", return_value=platform_enabled + ), patch( + "openedx_authz.utils.AUTHZ_COURSE_AUTHORING_FLAG", SimpleNamespace(name=FLAG_NAME) + ), patch( + "openedx_authz.utils.WaffleFlagOrgOverrideModel", self._mock_override_model([]) + ), patch( + "openedx_authz.utils.WaffleFlagCourseOverrideModel", self._mock_override_model([]) + ): + self.assertEqual(get_waffle_flag_states()["global"], platform_enabled) + + @data( + ([("Org1", "on")], {"on": ["Org1"], "off": []}), + ([("Org1", "off")], {"on": [], "off": ["Org1"]}), + ([("Org1", "on"), ("Org2", "off")], {"on": ["Org1"], "off": ["Org2"]}), + ([], {"on": [], "off": []}), + ) + @unpack + def test_org_tier_splits_active_overrides_by_choice(self, override_rows: list, expected: dict): + """Test get_waffle_flag_states' org key. + + Expected result: + - Orgs with an enabled override are split into 'on' and 'off' lists, + by the override's choice. + """ + with patch( + "openedx_authz.utils.enable_authz_course_authoring", return_value=False + ), patch( + "openedx_authz.utils.AUTHZ_COURSE_AUTHORING_FLAG", SimpleNamespace(name=FLAG_NAME) + ), patch( + "openedx_authz.utils.WaffleFlagOrgOverrideModel", self._mock_override_model(override_rows) + ), patch( + "openedx_authz.utils.WaffleFlagCourseOverrideModel", self._mock_override_model([]) + ): + self.assertEqual(get_waffle_flag_states()["org_overrides"], expected) + + @data( + ([("course-v1:Org1+COURSE1+2024", "on")], {"on": ["course-v1:Org1+COURSE1+2024"], "off": []}), + ([("course-v1:Org1+COURSE1+2024", "off")], {"on": [], "off": ["course-v1:Org1+COURSE1+2024"]}), + ( + [("course-v1:Org1+COURSE1+2024", "on"), ("course-v1:Org1+COURSE2+2024", "off")], + {"on": ["course-v1:Org1+COURSE1+2024"], "off": ["course-v1:Org1+COURSE2+2024"]}, + ), + ([], {"on": [], "off": []}), + ) + @unpack + def test_course_tier_splits_active_overrides_by_choice(self, override_rows: list, expected: dict): + """Test get_waffle_flag_states' course key. + + Expected result: + - Courses with an enabled override are split into 'on' and 'off' lists, + by the override's choice. Course keys are stringified. + """ + with patch( + "openedx_authz.utils.enable_authz_course_authoring", return_value=False + ), patch( + "openedx_authz.utils.AUTHZ_COURSE_AUTHORING_FLAG", SimpleNamespace(name=FLAG_NAME) + ), patch( + "openedx_authz.utils.WaffleFlagOrgOverrideModel", self._mock_override_model([]) + ), patch( + "openedx_authz.utils.WaffleFlagCourseOverrideModel", self._mock_override_model(override_rows) + ): + self.assertEqual(get_waffle_flag_states()["course_overrides"], expected) + + def test_all_three_tiers_are_independent(self): + """Test get_waffle_flag_states with each tier in a different state. + + Expected result: + - Each key reflects only its own tier, not a blend of the others. + """ + with patch( + "openedx_authz.utils.enable_authz_course_authoring", return_value=False + ), patch( + "openedx_authz.utils.AUTHZ_COURSE_AUTHORING_FLAG", SimpleNamespace(name=FLAG_NAME) + ), patch( + "openedx_authz.utils.WaffleFlagOrgOverrideModel", self._mock_override_model([("Org1", "on")]) + ), patch( + "openedx_authz.utils.WaffleFlagCourseOverrideModel", self._mock_override_model([]) + ): + self.assertEqual( + get_waffle_flag_states(), + {"global": False, "org_overrides": {"on": ["Org1"], "off": []}, "course_overrides": {"on": [], "off": []}}, + ) diff --git a/openedx_authz/utils.py b/openedx_authz/utils.py index 7d5ba16a..f959c0ce 100644 --- a/openedx_authz/utils.py +++ b/openedx_authz/utils.py @@ -5,20 +5,25 @@ from edx_django_utils.cache import RequestCache try: + # common.djangoapps.student.roles and openedx.core are edx-platform's own modules. This app + # is an edx-platform plugin, so they're always available at runtime; the imports are only + # guarded so this module can still load under this repo's own standalone test suite + # (openedx_authz.settings.test, no edx-platform installed). + from common.djangoapps.student.roles import enable_authz_course_authoring from openedx.core.djangoapps.waffle_utils.models import ( WaffleFlagCourseOverrideModel, WaffleFlagOrgOverrideModel, ) from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG except ImportError: + enable_authz_course_authoring = None WaffleFlagCourseOverrideModel = None WaffleFlagOrgOverrideModel = None AUTHZ_COURSE_AUTHORING_FLAG = None -from waffle.models import Flag - # Match handlers.py semantics: an override forces ON when override_choice == "on" WAFFLE_OVERRIDE_FORCE_ON = "on" +WAFFLE_OVERRIDE_FORCE_OFF = "off" User = get_user_model() @@ -75,32 +80,39 @@ def get_waffle_flag_states() -> dict: Returns: dict: A dictionary mapping scopes to their activation status: * 'global' (bool): True if the global waffle flag is enabled. - * 'org' (bool): True if at least one organization-level override is enabled. - * 'course' (bool): True if at least one course-level override is enabled. + * 'org_overrides' (dict): Orgs with an organization-level override, as 'on' + (forces the flag on) and 'off' (forces the flag off) lists. + * 'course_overrides' (dict): Courses with a course-level override, split the same way. """ - # Global flag (falls back False if toggle not available) - global_enabled = False - if AUTHZ_COURSE_AUTHORING_FLAG is not None: - gf = Flag.objects.filter(name=AUTHZ_COURSE_AUTHORING_FLAG.name).first() - global_enabled = bool(gf and gf.everyone) - - org_active = False - course_active = False - - # Check if any org-level override currently forces the flag on (no org filter) - if WaffleFlagOrgOverrideModel is not None and AUTHZ_COURSE_AUTHORING_FLAG is not None: - org_active = WaffleFlagOrgOverrideModel.objects.current_set().filter( - waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG.name, - enabled=True, - override_choice=WAFFLE_OVERRIDE_FORCE_ON, - ).exists() - - # Check if any course-level override currently forces the flag on (no course filter) - if WaffleFlagCourseOverrideModel is not None and AUTHZ_COURSE_AUTHORING_FLAG is not None: - course_active = WaffleFlagCourseOverrideModel.objects.current_set().filter( - waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG.name, - enabled=True, - override_choice=WAFFLE_OVERRIDE_FORCE_ON, - ).exists() - - return {"global": global_enabled, "org": org_active, "course": course_active} + global_enabled = bool(enable_authz_course_authoring()) + + # There's no public edx-platform API to get which orgs/courses have an override, only + # override_value(name, key) for one specific org/course at a time, so this queries the + # model directly. This is a temporary solution which should be addressed by + # https://github.com/openedx/openedx-authz/issues/360 + org_override_rows = list( + WaffleFlagOrgOverrideModel.objects.current_set() + .filter(waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG.name, enabled=True) + .values_list("org", "override_choice") + ) + course_override_rows = list( + WaffleFlagCourseOverrideModel.objects.current_set() + .filter(waffle_flag=AUTHZ_COURSE_AUTHORING_FLAG.name, enabled=True) + .values_list("course_id", "override_choice") + ) + + return { + "global": global_enabled, + "org_overrides": { + "on": [org for org, choice in org_override_rows if choice == WAFFLE_OVERRIDE_FORCE_ON], + "off": [org for org, choice in org_override_rows if choice == WAFFLE_OVERRIDE_FORCE_OFF], + }, + "course_overrides": { + "on": [ + str(course_id) for course_id, choice in course_override_rows if choice == WAFFLE_OVERRIDE_FORCE_ON + ], + "off": [ + str(course_id) for course_id, choice in course_override_rows if choice == WAFFLE_OVERRIDE_FORCE_OFF + ], + }, + } From 1cb34577354e8a8b0cdc9b2db0bd8a288c821acf Mon Sep 17 00:00:00 2001 From: Maria Fernanda Magallanes Zubillaga Date: Tue, 14 Jul 2026 15:27:26 -0500 Subject: [PATCH 3/7] fix: rollback to use Flag instead of enable_authz_course_authoring, because that needs an argument --- openedx_authz/tests/test_utils.py | 30 +++++++++++++++++++++++++----- openedx_authz/utils.py | 16 ++++++++-------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/openedx_authz/tests/test_utils.py b/openedx_authz/tests/test_utils.py index ce000397..e67a309b 100644 --- a/openedx_authz/tests/test_utils.py +++ b/openedx_authz/tests/test_utils.py @@ -213,10 +213,15 @@ def test_global_tier_follows_the_platform_flag(self, platform_enabled: bool): """Test get_waffle_flag_states' global key. Expected result: - - Matches enable_authz_course_authoring()'s platform-tier result. + - Matches global waffle flag result. """ with patch( - "openedx_authz.utils.enable_authz_course_authoring", return_value=platform_enabled + "openedx_authz.utils.Flag", + MagicMock(objects=MagicMock( + filter=MagicMock(return_value=MagicMock(first=MagicMock( + return_value=SimpleNamespace(everyone=platform_enabled) + ))) + )), ), patch( "openedx_authz.utils.AUTHZ_COURSE_AUTHORING_FLAG", SimpleNamespace(name=FLAG_NAME) ), patch( @@ -241,7 +246,12 @@ def test_org_tier_splits_active_overrides_by_choice(self, override_rows: list, e by the override's choice. """ with patch( - "openedx_authz.utils.enable_authz_course_authoring", return_value=False + "openedx_authz.utils.Flag", + MagicMock(objects=MagicMock( + filter=MagicMock(return_value=MagicMock(first=MagicMock( + return_value=SimpleNamespace(everyone=False) + ))) + )), ), patch( "openedx_authz.utils.AUTHZ_COURSE_AUTHORING_FLAG", SimpleNamespace(name=FLAG_NAME) ), patch( @@ -269,7 +279,12 @@ def test_course_tier_splits_active_overrides_by_choice(self, override_rows: list by the override's choice. Course keys are stringified. """ with patch( - "openedx_authz.utils.enable_authz_course_authoring", return_value=False + "openedx_authz.utils.Flag", + MagicMock(objects=MagicMock( + filter=MagicMock(return_value=MagicMock(first=MagicMock( + return_value=SimpleNamespace(everyone=False) + ))) + )), ), patch( "openedx_authz.utils.AUTHZ_COURSE_AUTHORING_FLAG", SimpleNamespace(name=FLAG_NAME) ), patch( @@ -286,7 +301,12 @@ def test_all_three_tiers_are_independent(self): - Each key reflects only its own tier, not a blend of the others. """ with patch( - "openedx_authz.utils.enable_authz_course_authoring", return_value=False + "openedx_authz.utils.Flag", + MagicMock(objects=MagicMock( + filter=MagicMock(return_value=MagicMock(first=MagicMock( + return_value=SimpleNamespace(everyone=False) + ))) + )), ), patch( "openedx_authz.utils.AUTHZ_COURSE_AUTHORING_FLAG", SimpleNamespace(name=FLAG_NAME) ), patch( diff --git a/openedx_authz/utils.py b/openedx_authz/utils.py index f959c0ce..87f0e780 100644 --- a/openedx_authz/utils.py +++ b/openedx_authz/utils.py @@ -5,22 +5,18 @@ from edx_django_utils.cache import RequestCache try: - # common.djangoapps.student.roles and openedx.core are edx-platform's own modules. This app - # is an edx-platform plugin, so they're always available at runtime; the imports are only - # guarded so this module can still load under this repo's own standalone test suite - # (openedx_authz.settings.test, no edx-platform installed). - from common.djangoapps.student.roles import enable_authz_course_authoring from openedx.core.djangoapps.waffle_utils.models import ( WaffleFlagCourseOverrideModel, WaffleFlagOrgOverrideModel, ) from openedx.core.toggles import AUTHZ_COURSE_AUTHORING_FLAG except ImportError: - enable_authz_course_authoring = None WaffleFlagCourseOverrideModel = None WaffleFlagOrgOverrideModel = None AUTHZ_COURSE_AUTHORING_FLAG = None +from waffle.models import Flag + # Match handlers.py semantics: an override forces ON when override_choice == "on" WAFFLE_OVERRIDE_FORCE_ON = "on" WAFFLE_OVERRIDE_FORCE_OFF = "off" @@ -79,12 +75,16 @@ def get_waffle_flag_states() -> dict: Returns: dict: A dictionary mapping scopes to their activation status: - * 'global' (bool): True if the global waffle flag is enabled. + * 'global' (bool): True if the global waffle flag is enabled for everyone. * 'org_overrides' (dict): Orgs with an organization-level override, as 'on' (forces the flag on) and 'off' (forces the flag off) lists. * 'course_overrides' (dict): Courses with a course-level override, split the same way. """ - global_enabled = bool(enable_authz_course_authoring()) + # Global flag (falls back False if toggle not available) + global_enabled = False + if AUTHZ_COURSE_AUTHORING_FLAG is not None: + gf = Flag.objects.filter(name=AUTHZ_COURSE_AUTHORING_FLAG.name).first() + global_enabled = bool(gf and gf.everyone) # There's no public edx-platform API to get which orgs/courses have an override, only # override_value(name, key) for one specific org/course at a time, so this queries the From 8dabdf650b85e1d7debd69b58c57847cf5d16b54 Mon Sep 17 00:00:00 2001 From: Maria Fernanda Magallanes Zubillaga Date: Tue, 14 Jul 2026 16:38:20 -0500 Subject: [PATCH 4/7] fix: quality tests --- openedx_authz/tests/test_utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openedx_authz/tests/test_utils.py b/openedx_authz/tests/test_utils.py index e67a309b..7c68574b 100644 --- a/openedx_authz/tests/test_utils.py +++ b/openedx_authz/tests/test_utils.py @@ -316,5 +316,9 @@ def test_all_three_tiers_are_independent(self): ): self.assertEqual( get_waffle_flag_states(), - {"global": False, "org_overrides": {"on": ["Org1"], "off": []}, "course_overrides": {"on": [], "off": []}}, + { + "global": False, + "org_overrides": {"on": ["Org1"], "off": []}, + "course_overrides": {"on": [], "off": []}, + }, ) From 39f07b0476f3bc7379a79cddd22f24feef258f8b Mon Sep 17 00:00:00 2001 From: Maria Fernanda Magallanes Zubillaga Date: Tue, 14 Jul 2026 17:12:41 -0500 Subject: [PATCH 5/7] docs: bumpversion to 1.21.0 --- CHANGELOG.rst | 8 ++++++++ openedx_authz/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d9cf23cf..0fcf508a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,14 @@ Change Log Unreleased ********** +1.21.0 - 2026-07-14 +******************* + +Added +===== + +* Introduced a new REST API endpoint and utility functions to fetch course authoring waffle flag states. (#358) + 1.20.1 - 2026-07-03 ******************* diff --git a/openedx_authz/__init__.py b/openedx_authz/__init__.py index 469028cb..2c1e6d26 100644 --- a/openedx_authz/__init__.py +++ b/openedx_authz/__init__.py @@ -4,6 +4,6 @@ import os -__version__ = "1.20.1" +__version__ = "1.21.0" ROOT_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) From 05588e63f8d926a2f9ef64c3e9759cd658560a38 Mon Sep 17 00:00:00 2001 From: Maria Fernanda Magallanes Zubillaga Date: Wed, 15 Jul 2026 12:34:14 -0500 Subject: [PATCH 6/7] docs: update the adr --- ...expose-course-authoring-waffle-flag-state-via-rest-api.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst b/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst index bebd228f..d870b218 100644 --- a/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst +++ b/docs/decisions/0015-expose-course-authoring-waffle-flag-state-via-rest-api.rst @@ -15,7 +15,7 @@ Context `PR #361`_ attempted to enforce that full truth table directly inside ``PermissionValidationMeView`` and other REST API endpoints, checking the flag per scope on every request. Per `PR #361's own comment thread`_, those endpoints are release-blocking for Verawood, so baking precise per-scope flag logic into them risked correctness and performance on critical paths without enough test coverage across the framework to be confident in time for the release. That approach was reverted, and the team pivoted to `issue #358`_ instead, exposing the flag's raw state through a dedicated endpoint and letting the admin-console MFE apply the simpler #340/#341 rule itself, deferring precise per-scope filtering to a later cycle. -Neither edx-toggles nor edx-platform expose a public API for this. ``/api/toggles/v0/state/`` (`edx_toggles source`_) only reports the global waffle flag's ``everyone`` value, with no awareness of course or org overrides. ``WaffleFlagOrgOverrideModel.override_value(name, key)`` and its course-level counterpart (`waffle_utils models source`_) each answer for one specific org or course, not "which orgs/courses have an override." +Neither edx-toggles nor edx-platform expose a suitable public API for this client-facing use case. ``/api/toggles/v0/state/`` (`edx_toggles source`_) can expose override data, but it requires Django staff/admin access and exposes flag state broadly (not just ``authz.enable_course_authoring``). ``WaffleFlagOrgOverrideModel.override_value(name, key)`` and its course-level counterpart (`waffle_utils models source`_) each answer for one specific org or course, not "which orgs/courses have an override." Decision ******** @@ -41,7 +41,7 @@ Rejected Alternatives Correctness and performance across the whole framework weren't validated in time for a release-blocking change, per PR #361's own comment thread. The simpler #340/#341 rule doesn't need per-scope precision to ship. **Relying on** ``/api/toggles/v0/state/`` - This edx-toggles endpoint only reports the global flag value. It has no awareness of ``WaffleFlagOrgOverrideModel``/``WaffleFlagCourseOverrideModel``, so it cannot answer whether any org or course has an override, and it requires Django staff to call. + This edx-toggles endpoint can expose override data, but it requires Django staff/admin access and is not suitable for this use case. It also exposes flag state broadly (not just ``authz.enable_course_authoring``), which is a security risk for this use case. References ********** From ea7ac0b1c7f0e9d9e84e7b11ceca9c4eae869dfd Mon Sep 17 00:00:00 2001 From: Maria Fernanda Magallanes Zubillaga Date: Wed, 15 Jul 2026 13:59:03 -0500 Subject: [PATCH 7/7] chore: kick github to process updates