Repository navigation
feat: add read-only REST endpoint for CBE rule profiles #846
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ormsbee
merged 1 commit into
openedx:main
from
javoconsultant:javoconsultant/773-read-rule-profiles
Oct 13, 2026
+693
−5
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| """ | ||
| Paginators for the CBE REST API. | ||
| These sit outside the versioned package, as openedx_tagging's do, because a page size is | ||
| version independent. | ||
| """ | ||
| from edx_rest_framework_extensions.paginators import DefaultPagination # type: ignore[import] | ||
|
|
||
|
|
||
| class CompetencyRuleProfilePagination(DefaultPagination): | ||
| """ | ||
| Page size for the competency rule profile collection. | ||
| Pinned here rather than inherited from the consuming project's DEFAULT_PAGINATION_CLASS, the | ||
| same way openedx_tagging pins TaxonomyPagination: this is a published library, so the page | ||
| size is part of its REST contract instead of varying by deployment. | ||
| """ | ||
|
|
||
| page_size = 100 | ||
| max_page_size = 500 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| """ | ||
| CBE API URLs. | ||
| """ | ||
|
|
||
| from django.urls import include, path | ||
|
|
||
| from .v1 import urls as v1_urls | ||
|
|
||
| # Namespaces the whole CBE API, so routes reverse as "cbe:<name>". Declared here rather than in | ||
| # v1/ because the namespace spans every version, and rather than on the app-root module because | ||
| # that one aggregates every applet. | ||
| app_name = "cbe" | ||
|
|
||
| urlpatterns = [path("v1/", include(v1_urls))] |
Empty file.
19 changes: 19 additions & 0 deletions
19
src/openedx_learning/applets/cbe/rest_api/v1/permissions.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| """ | ||
| Permissions for the CBE REST API, v1. | ||
| """ | ||
| from rest_framework.permissions import DjangoObjectPermissions | ||
|
|
||
|
|
||
| class CompetencyRuleProfilePermissions(DjangoObjectPermissions): | ||
| """ | ||
| Maps each REST API method to its corresponding CompetencyRuleProfile permission. | ||
| Only the read methods are mapped, so DRF answers a write attempt with 405 rather than | ||
| checking a permission that no write endpoint would honor anyway. | ||
| """ | ||
|
|
||
| perms_map = { | ||
| "GET": ["%(app_label)s.view_%(model_name)s"], | ||
| "OPTIONS": [], | ||
| "HEAD": ["%(app_label)s.view_%(model_name)s"], | ||
| } |
56 changes: 56 additions & 0 deletions
56
src/openedx_learning/applets/cbe/rest_api/v1/serializers.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| """ | ||
| Serializers for the CBE REST API, v1. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| from rest_framework import serializers | ||
|
|
||
| from ...models import CompetencyRuleProfile | ||
|
|
||
|
|
||
| class CompetencyRuleProfileSerializer(serializers.ModelSerializer): | ||
| """ | ||
| Read-only representation of a CompetencyRuleProfile. | ||
|
|
||
| UNSTABLE: the rule profile family is incomplete, so the create, update, and archive | ||
| endpoints still to come may change this shape without a deprecation cycle. | ||
|
|
||
| ``rule_payload`` is emitted verbatim as stored. For ``Grade``, the only rule type | ||
| supported in this phase, that shape is ``{"op": ..., "value": ..., "scale": ...}``: | ||
| ``op`` is one of ``gte``, ``lte``, or ``eq``, and ``value`` is a fraction between 0.0 | ||
| and 1.0 inclusive. ``GradeRulePayload`` and ``validate_rule_payload`` in the | ||
| ``rule_payloads`` module own that shape, so it is not renormalized here. ``scale`` is | ||
| always ``"percent"`` for now; it tells a client how to display ``value`` (``0.8`` means 80%). | ||
|
|
||
| ``scope_code`` and the raw ``organization``, ``course``, and ``competency_taxonomy`` | ||
| columns are left out: ``scope_type`` below is what a client can act on, while the | ||
| others are internal, existing only to enforce the one-profile-per-scope constraint | ||
| (:ref:`openedx-learning-adr-0002` Decision 3). | ||
| """ | ||
|
|
||
| scope_type = serializers.SerializerMethodField() | ||
|
|
||
| class Meta: | ||
| model = CompetencyRuleProfile | ||
| fields = ["id", "scope_type", "rule_type", "rule_payload", "archived"] | ||
| # scope_type is absent here because DRF refuses a field that is both declared above and | ||
| # named in read_only_fields; a SerializerMethodField is read-only in any case. | ||
| read_only_fields = ["id", "rule_type", "rule_payload", "archived"] | ||
|
|
||
| def get_scope_type(self, profile: CompetencyRuleProfile) -> str: | ||
| """ | ||
| Return which kind of scope ``profile`` applies to. | ||
|
|
||
| All four kinds are recognized from the outset, even though only the system default can | ||
| exist today, so enabling a narrower scope needs no edit here. The scope columns are read | ||
| by their ``_id`` attributes so that no row costs a query, and the system default is | ||
| recognized by those columns being null rather than by matching the internal | ||
| ``scope_code`` string. | ||
| """ | ||
| if profile.competency_taxonomy_id is not None: | ||
| return "taxonomy" | ||
| if profile.course_id is not None: | ||
| return "course" | ||
| if profile.organization_id is not None: | ||
| return "organization" | ||
| return "system_default" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| """ | ||
| CBE API v1 URLs. | ||
| """ | ||
|
|
||
| from django.urls import include, path | ||
| from rest_framework.routers import DefaultRouter | ||
|
|
||
| from . import views | ||
|
|
||
| router = DefaultRouter() | ||
| router.register("rule_profiles", views.CompetencyRuleProfileView, basename="rule_profile") | ||
|
|
||
| urlpatterns = [ | ||
| path("", include(router.urls)), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| """ | ||
| Views for the CBE REST API, v1. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| from django.db.models import QuerySet | ||
| from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication # type: ignore[import] | ||
| from edx_rest_framework_extensions.auth.session.authentication import ( # type: ignore[import] | ||
| SessionAuthenticationAllowInactiveUser, | ||
| ) | ||
| from rest_framework import mixins | ||
| from rest_framework.viewsets import GenericViewSet | ||
|
|
||
| from ...api import get_competency_rule_profiles | ||
| from ...models import CompetencyRuleProfile | ||
| from ..paginators import CompetencyRuleProfilePagination | ||
| from .permissions import CompetencyRuleProfilePermissions | ||
| from .serializers import CompetencyRuleProfileSerializer | ||
|
|
||
|
|
||
| class CompetencyRuleProfileView(mixins.ListModelMixin, GenericViewSet): | ||
| """ | ||
| Read the rule profiles this instance defines. | ||
|
|
||
| ``GET api/cbe/v1/rule_profiles/`` lists them to any signed-in caller, paginated by ``page`` | ||
| and ``page_size``; ``can_view_competency_rule_profile`` holds the gate. | ||
|
|
||
| UNSTABLE: the rule profile family is incomplete, so the create, update, and archive | ||
| endpoints still to come may change this shape without a deprecation cycle. | ||
|
|
||
| This is a collection even while the seeded system default is the only profile an instance | ||
| holds, and it is paginated from the first release: wrapping a bare array in an envelope later | ||
| would change the top-level JSON type. A viewset rather than a ListAPIView, so the deferred | ||
| create and detail routes can be added as further mixins without touching the URL module. | ||
| """ | ||
|
|
||
| serializer_class = CompetencyRuleProfileSerializer | ||
| permission_classes = [CompetencyRuleProfilePermissions] | ||
| pagination_class = CompetencyRuleProfilePagination | ||
| # Set here rather than through openedx_tagging's view_auth_classes decorator, which lives in | ||
| # another app's REST internals rather than in an API this library publishes. | ||
| authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) | ||
|
|
||
| def get_queryset(self) -> QuerySet[CompetencyRuleProfile]: | ||
| """Return the live rule profiles, filtered and ordered by the applet's public API.""" | ||
| return get_competency_rule_profiles() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| """ | ||
| Django rules-based permissions for Competency-Based Education (CBE). | ||
| Registered with the ``rules`` permission registry on import. ``src/openedx_learning/rules.py`` | ||
| is what makes that import happen; see the note there. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import rules | ||
|
|
||
| from openedx_tagging.rules import UserType | ||
|
|
||
| from .models import CompetencyRuleProfile | ||
|
|
||
| __all__ = [ | ||
| "can_view_competency_rule_profile", | ||
| ] | ||
|
|
||
|
|
||
| @rules.predicate | ||
| def can_view_competency_rule_profile( | ||
| user: UserType, | ||
| profile: CompetencyRuleProfile | None = None, | ||
| ) -> bool: | ||
| """ | ||
| Decide who may read a rule profile, by the profile's scope. | ||
| Course authors, not only platform staff, need to read rule profiles, so the collection and | ||
| the system default are open to anyone signed in. A taxonomy-scoped profile is as visible as | ||
| its taxonomy, which is the gate the criteria tree endpoint uses too, through | ||
| ``oel_tagging.view_tag``; a profile on a disabled taxonomy stays admin-only. Course- and | ||
| organization-scoped profiles are refused to everyone until #645 decides who may see them. | ||
| The signed-in requirement is added where the permission is registered, below, so ``has_perm`` | ||
| refuses an anonymous caller whatever view sits in front of it. This predicate alone does not | ||
| check it. | ||
| The taxonomy case asks ``has_perm`` rather than calling ``can_view_taxonomy``: openedx-platform | ||
| replaces that rule with an org-aware one via ``rules.set_perm``, which a direct call skips. | ||
| """ | ||
| if profile is None: | ||
| # Listing. Any row-level filtering happens in the queryset. | ||
| return True | ||
| if profile.competency_taxonomy_id is not None: | ||
| return user.has_perm("oel_tagging.view_taxonomy", profile.competency_taxonomy) | ||
| if profile.course_id is None and profile.organization_id is None: | ||
| # The system default: anyone who is signed in may read it. | ||
| return True | ||
| # Course- and organization-scoped profiles: no authoring flow creates these yet, and neither | ||
| # tagging permission describes who may see them. Refused until #645 decides. | ||
| return False | ||
|
|
||
|
|
||
| rules.add_perm( | ||
| "openedx_learning.view_competencyruleprofile", | ||
| rules.is_authenticated & can_view_competency_rule_profile, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| """ | ||
| This is where the openedx_learning app's rules-based permissions are registered. | ||
| ``rules.apps.AutodiscoverRulesConfig`` imports ``<app_package>.rules`` and nothing deeper, so | ||
| an applet's permissions reach the registry only by being pulled in from here. | ||
| """ | ||
| # This wildcard import is okay because the applet rules module declares __all__. | ||
| # pylint: disable=wildcard-import | ||
| from .applets.cbe.rules import * |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| """ | ||
| Learning API URLs. | ||
| Maps each applet onto its own URL segment. A consuming project includes this module and never an | ||
| applet package directly, so ``applets/`` stays the internal layout detail that api.py and | ||
| models.py already exist to hide. | ||
| No ``app_name`` here on purpose: each applet declares its own, so route names stay unique per | ||
| applet rather than sharing one flat namespace. | ||
| """ | ||
|
|
||
| from django.urls import include, path | ||
|
|
||
| from .applets.cbe.rest_api import urls as cbe_urls | ||
|
|
||
| urlpatterns = [path("cbe/", include(cbe_urls))] | ||
|
ormsbee marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.