openedx / openedx/openedx-core
[BE] Read the rule profiles an instance defines
@javoconsultant is already working on this.
Since Sep 4, 2026.
- Dominant language
- Python
- Stars
- 10
- Forks
- 32
- Avg merge
- 2d 17h
- Merged PRs (30d)
- 12
Description
User Story
As a user of the Competency Management page, I want the Competency Management page to be aware of the rule profiles an instance defines, in order to know what the instance currently requires for mastery before deciding whether a criterion should follow that rule or override it.
Acceptance Criteria
# Reading the rule
Scenario: Read the rule an instance currently requires for mastery
Given an instance whose only rule profile is the instance-wide default
And a caller permitted to administer instance-wide competency configuration
When the caller requests the rule profiles
Then the caller receives a collection containing that one profile
And the profile reports the rule learners are evaluated against
And the profile reports that it applies instance-wide rather than to one taxonomy, course, or organization
And the profile carries a reference by which a criterion can later be pointed at it
Scenario: Report a rule complete enough for a caller to act on without guessing
Given an instance whose instance-wide default requires a grade at or above a given fraction
When the caller requests the rule profiles
Then the profile reports which kind of rule it evaluates, the comparison it applies,
and the threshold it compares against
And the threshold is reported together with the scale it is expressed on,
so a caller cannot read the fraction as a percentage or the percentage as a fraction
# Staying correct once narrower profiles exist
Scenario: Tell the instance-wide default apart from a narrower profile
Given an instance with a rule profile scoped to a single taxonomy alongside the instance-wide default
When the caller requests the rule profiles
Then every returned profile reports the scope it applies to
And the caller can identify the instance-wide default from the scope each profile reports,
without depending on how many profiles were returned or on the order they arrived in
# Verified via a unit test creating the taxonomy-scoped row directly as a fixture: no create
# endpoint exists yet to reach this state through a live call, so this is not Postman-testable.
Scenario: Read a collection larger than one response carries
Given more rule profiles exist than a single response carries at once
When the caller requests the rule profiles
Then the caller receives part of the collection together with a way to request the remainder
And requesting the remainder yields the profiles not already returned, with none repeated and none skipped
# Verified via a unit test creating enough rows directly as fixtures to exceed one page: no
# create endpoint exists yet to reach this state through a live call, so this is not
# Postman-testable.
Scenario: Return the profiles in a stable order
Given an instance with several rule profiles
When the caller requests the rule profiles twice, with no profile changing in between
Then the profiles arrive in the same order both times
# Verified via a unit test creating several rows directly as fixtures: no create endpoint
# exists yet to reach this state through a live call, so this is not Postman-testable.
# What the collection leaves out
Scenario: Leave retired profiles out of the collection
Given an instance with a retired rule profile alongside the instance-wide default
When the caller requests the rule profiles
Then the retired profile is not among those returned
And the instance-wide default is returned
# Verified via a unit test creating a retired row directly as a fixture: no archive endpoint
# exists yet to reach this state through a live call, so this is not Postman-testable.
Scenario: Keep the instance's internal scope bookkeeping out of the response
Given an instance with the instance-wide default rule profile
When the caller requests the rule profiles
Then the response describes each profile's scope in terms a caller outside this system can act on
And it does not expose the internal value the system uses to enforce one profile per scope
# Empty and refused cases
Scenario: Report an instance with no rule profiles as empty rather than as a failure
Given an instance where no rule profile exists
When the caller requests the rule profiles
Then the caller receives an empty collection
And the request is not reported as having failed
# This is the scenario that fails if the endpoint was built as a single-object read.
# Verified via a unit test against a database with no seeded row: #613's migration seeds this
# row on every real deployment, so this state is not reachable on a live instance and is not
# Postman-testable.
Scenario: Refuse a caller who may not author competency criteria
Given a caller who is not permitted to administer instance-wide competency configuration
When the caller requests the rule profiles
Then the request is refused
And no rule profile is returned
Scenario: Refuse a caller the system cannot identify
Given a caller the system cannot identify
When the caller requests the rule profiles
Then the request is refused
And no rule profile is returned
Description
What nothing exposes is the rule the profile holds: no caller can read the comparison and the threshold that mastery currently requires, so nothing can report it, record it, or judge whether the shared default suits a given criterion.
The instance-wide default is the only rule profile this phase creates, and it is seeded rather than authored. Profiles scoped to a taxonomy, a course, or an organization exist in the data model, but no authoring flow creates one yet.
Technical Details
This section is background and a suggested approach, not the source of truth. The User Story and Acceptance Criteria define what must be true when the work is done; the notes below exist to save the implementer some thinking.
In short
What the endpoint is. A single collection route, rule_profiles/, returning every non-archived CompetencyRuleProfile the requester may read, in the standard paginated envelope with the profiles under results. There is no scope filter and no separate route for the system default. Today the instance holds exactly one profile, the seeded system default, so the collection has one element. The endpoint is still a collection in every respect, and a client that treats it as a singleton will be wrong the day a second scope is enabled.
How a client tells the profiles apart. Each row carries a derived scope_type field, so a client identifies a profile by value rather than by its position in the list. The derivation recognizes all four scope kinds from the outset, so it never has to be edited when a scope is enabled. The lookup that finds the system default queries the three scope columns for null; it never matches on the scope_code string, because ADR 0002 Decision 3 states that column embeds internal identifier references, exists solely to enforce uniqueness, and is not intended to be exported or exposed outside this system.
Why the response is paginated from the first commit. ADR 0002 Decision 2's closing boundaries state that pagination is supported for authoring and list APIs. Beyond that, pagination is the one thing here that cannot be added later without breaking clients: an unpaginated list is a bare JSON array, and wrapping it in an envelope changes the top-level JSON type. Because this repository is a published library, the page sizes are pinned on the view rather than inherited from the consuming project, so the envelope and the page size are part of this contract instead of varying by deployment.
What the response carries, and what it deliberately omits. Each row carries an identifier, a scope type, the rule type, the rule payload, and whether the profile is retired. The identifier is the model's own primary key, because that is what the criterion-update endpoint accepts (#759), and because the tagging app's REST API already exposes the primary key for taxonomies, which have no external identifier either. The retired flag is included even though today's active-only filter means it is always false, because CompetencyRuleProfile is exempt from hard deletion entirely per ADR 0002 Decision 7: retirement is archive-only, retired rows are permanent and stay queryable, and a client must be able to tell one apart without a later contract change. Three things are omitted on purpose. scope_code is omitted for the reason above. The three raw scope columns are omitted because the course and organization references are internal foreign keys that mean nothing to an authoring client, and emitting them as always-null today would commit the contract to that representation before any caller needs it. An in-use indicator is omitted because the guardrail it serves fires when a profile is edited, and no write endpoint is in scope here.
Why the collection is active-only, and why that is decided now. Because retired profiles are never deleted, they accumulate permanently, so an unfiltered collection that included them would degrade without bound. Deciding this now also avoids a later behavior change: if the collection meant "everything" today and narrowed to "active" later, clients would notice. A parameter to include retired rows is a plausible future addition and is out of scope, as are scope filters; both are additive, because a client that sends no parameters keeps today's behavior.
Who may read it, and how that gate composes with future scopes. Reading is gated on a new permission whose predicate reuses the tagging app's existing taxonomy-administrator check, which resolves to platform staff. The system default is instance-wide competency configuration administered by the same people who administer taxonomies, and defining a second notion of administrator inside CBE would let the two drift. Starting staff-only is the safe direction, because loosening a read gate later gives clients more data and is not a breaking change, whereas tightening one is. The predicate accepts an optional profile instance from the start, so when scoped profiles arrive with a possibly broader audience, that work adds a branch to one predicate and filters rows in the queryset, rather than refusing a whole request in order to hide rows.
What this ticket extends, and what it depends on. The CBE REST package, the app-root URL configuration, the applet's public API module, and the mount that makes CBE reachable from Studio all already exist, established by #664. This ticket adds one API function, one serializer, one viewset, one router registration, a pinned paginator, and the first CBE-local permission, plus tests. It adds no model, no migration, and no openedx-platform work of its own. It depends on #613 for the CompetencyRuleProfile model and for the seeded system-default row, and it does not seed that row itself. If no row has been seeded, the endpoint reports an empty collection rather than an error, because a collection with no members is an empty collection and not a missing resource.
Implementation specifics
- Applet API function.
get_competency_rule_profiles() -> QuerySet[CompetencyRuleProfile]insrc/openedx_learning/applets/cbe/api.py, added to that module's existing__all__, returningCompetencyRuleProfile.objects.filter(archived=False).order_by("id"). Returning a lazy queryset from a public API function followsopenedx_tagging.api.get_taxonomies, whichTaxonomyView.get_querysetconsumes the same way, and it is what makes pagination work. - The
competency_name prefix is required. The umbrellasrc/openedx_learning/api.pyflattens every applet's API into one namespace shared withlearning_pathways. - Umbrella re-export needs no edit, provided the umbrella wildcard-imports the applet API modules and the applet declares
__all__.src/openedx_content/api.pyis the working example, and its own comment records why the wildcard is safe. - Deterministic ordering is mandatory, not cosmetic. An unordered queryset produces inconsistent pages and triggers Django's
UnorderedObjectListWarning. Order byidascending, and do not order the system default first: a stable position would invite clients to identify profiles positionally, which is the assumption this endpoint exists to prevent. - Serializer.
CompetencyRuleProfileSerializer(serializers.ModelSerializer)insrc/openedx_learning/applets/cbe/rest_api/v1/serializers.py, withfields = ["id", "scope_type", "rule_type", "rule_payload", "archived"]and every field read-only. scope_typederivation. ASerializerMethodFieldreturning"system_default"when all three scope columns are null, and otherwise"taxonomy","course", or"organization"for whichever column is non-null. When a second scope kind is actually enabled, move that derivation onto the model so non-REST callers, in particular the profile-assignment computation in ADR 0002 Decision 4, share one definition; a single caller does not justify the move yet.- Return
rule_payloadverbatim as stored, without normalizing it. ADR 0002 Decision 3 fixes the threshold as a fraction between 0.0 and 1.0 inclusive, "matching the platform's existing fractional grade representation, not a 0-100 scale", while its own worked example pairs a value of0.75with ascaleof"percent". That ambiguity is why the scale must be reported alongside the threshold, and why this endpoint must not become a second definition of the payload contract. Cover the pairing in a test asserting the exact stored shape. If the seeded row turns out to omitscale, that is a defect in the seed and belongs to #613; raise it rather than defaulting the key here. scope_typevalues are snake_case lowercase,rule_typevalues are CamelCase ("Grade"), because ADR 0002 defines them that way. Do not harmonize the two; the rule type's casing belongs to the model.- Viewset.
CompetencyRuleProfileView(mixins.ListModelMixin, GenericViewSet)insrc/openedx_learning/applets/cbe/rest_api/v1/views.py, withserializer_class,permission_classes,pagination_class, and aget_querysetreturningget_competency_rule_profiles(). Use a viewset rather than aListAPIViewso that create and a by-identifier detail route can later be added as extra mixins with no change to the URL module. - Set
authentication_classeson the viewset class directly. Do not importview_auth_classesfromopenedx_tagging/rest_api/utils.py, which is another app's REST internals rather than its public API. - URL registration. Add
router.register("rule_profiles", views.CompetencyRuleProfileView, basename="rule_profile")to the router already defined insrc/openedx_learning/applets/cbe/rest_api/v1/urls.py. Do not create a second router or a second URL module. The path segment is underscored, matchingobject_tagsandobject_tag_countsinsrc/openedx_tagging/rest_api/v1/urls.py. - The client-visible path is inherited, not decided here. #664 mounts CBE at
api/cbe/rest_api/, so this endpoint resolves toapi/cbe/rest_api/v1/rule_profiles/. This ticket does not revisit that prefix. - Pagination class.
CompetencyRuleProfilePaginationin a newsrc/openedx_learning/applets/cbe/rest_api/paginators.py, subclassingDefaultPaginationfromedx_rest_framework_extensions.paginators, with explicitpage_sizeandmax_page_sizerather than inheriting the consuming project'sDEFAULT_PAGINATION_CLASS. The tagging app pinsTaxonomyPaginationandTagsPaginationthe same way. Keep the module atrest_api/rather thanrest_api/v1/, matching the tagging app, because pagination is version-independent. - Permission predicate.
can_view_competency_rule_profile(user, profile=None) -> boolin a newsrc/openedx_learning/applets/cbe/rules.py, returningis_taxonomy_admin(user)imported fromopenedx_tagging.rules, registered withrules.add_perm. Theprofileargument is optional and currently unused. - Permission scaffolding is new to CBE. #664 gates its endpoint by reusing the tagging app's
TaxonomyObjectPermissionsandcan_change_taxonomydirectly and explicitly introduces no CBE permission, so this ticket adds the applet'srules.pyand itsrest_api/v1/permissions.py. If either module exists by the time this is picked up, extend it rather than creating a parallel one. - Rules autodiscovery requires an app-root module. The rules autodiscovery app config imports
<app_package>.rules, sosrc/openedx_learning/rules.pymust exist and import the applet's rules module for the permission to register, mirroring how the umbrella API module pulls in its applets. - DRF permission class.
CompetencyRuleProfilePermissions(DjangoObjectPermissions)insrc/openedx_learning/applets/cbe/rest_api/v1/permissions.py, with aperms_mapgranting the read methods the view permission and an empty list forOPTIONS, matchingTaxonomyObjectPermissions. The base class resolves the model throughview.get_queryset(), which is lazy, so no query is issued during the permission check. An unidentified caller gets 401 and an identified but unpermitted caller gets 403 from DRF, with no CBE-specific handling. - Mark the API unstable.
README.rststates that APIs not marked "UNSTABLE" are considered stable and that breaking them goes through the community deprecation process. Mark bothget_competency_rule_profilesand the viewset UNSTABLE in their docstrings while the rule-profile family is incomplete, so the deferred create, update, and archive endpoints can adjust the shape without a deprecation cycle. There is no existing use of the marker anywhere insrc/, so this establishes the pattern rather than copying one. - Layering is a dependency, not a task here.
openedx_learningis registered in.importlinter'sroot_packagesand placed in the layering contract by #613, andlint-importsmust pass. Worth knowing: a top-level package absent fromroot_packagesis silently exempt from every layering contract, so CBE imports would go unchecked with no failing build to reveal it. - No PII annotation is needed. There is no new model and no migration, so
make pii_checkis unaffected; annotations belong with the models in #613. - Tests go in
tests/openedx_learning/applets/cbe/, mirroringtests/openedx_content/applets/, split intotest_api.py,test_views.py, andtest_rules.pyastests/openedx_tagging/is split. This repository keeps every test under the top-leveltests/tree and has none undersrc/. Migrations run in the test database, so the seeded system-default row is already present; do not write tests that assume an empty table. - Specific cases to cover:
- A permitted request returns one result whose scope type is the instance-wide one and whose payload matches the seeded row exactly, including its scale key.
- The response contains no
scope_codekey and no scope-column keys. - The lookup still succeeds when a row's
scope_codeis unrelated to the query, which proves the null-column query rather than a string match is in use. - A taxonomy-scoped row created directly in a fixture appears in the same collection alongside the default, with the taxonomy scope type and in identifier order. This is the actual proof that a second scope is additive.
- A retired row created in a fixture is excluded.
- An empty table reports an empty collection rather than a missing resource.
- An unpermitted identified caller is refused, and an unidentified caller is refused.
- The response is the paginated envelope rather than a bare array.
- Out of scope, named so nobody builds it here:
- Creating, updating, or archiving a profile.
- An in-use indicator on the response, whose definition is pinned to ADR 0003 Decision 4 on #613 if it is ever added.
- Scope filters and scope reference fields. Until a scope reference field exists, a non-default row tells a client the kind of scope but not which object is scoped, so a client must not try to act on one.
- A by-identifier detail route.
- An exception handler module, which the deferred write endpoints will need for their conflict response.
- Django admin registration.
- Seeding the system-default row, which is #613's.
Files to create and modify New files
| File | Purpose |
|---|---|
| src/openedx_learning/applets/cbe/rest_api/paginators.py | CompetencyRuleProfilePagination |
| src/openedx_learning/applets/cbe/rest_api/v1/permissions.py | CompetencyRuleProfilePermissions |
| src/openedx_learning/applets/cbe/rules.py | can_view_competency_rule_profile and its permission registration |
| src/openedx_learning/rules.py | app-root rules module, imported by rules autodiscovery |
| tests/openedx_learning/applets/cbe/test_rules.py | permission predicate tests |
Modified files
| File | Nature of modification |
|---|---|
| src/openedx_learning/applets/cbe/api.py | add get_competency_rule_profiles() and extend __all__ |
| src/openedx_learning/applets/cbe/rest_api/v1/serializers.py | add CompetencyRuleProfileSerializer |
| src/openedx_learning/applets/cbe/rest_api/v1/views.py | add CompetencyRuleProfileView |
| src/openedx_learning/applets/cbe/rest_api/v1/urls.py | register rule_profiles on the existing router |
| tests/openedx_learning/applets/cbe/test_api.py | add filtering, ordering, and null-scope lookup tests |
| tests/openedx_learning/applets/cbe/test_views.py | add response-shape, envelope, empty-collection, and refusal tests |
- Context ADR 0002,
docs/openedx_learning/decisions/0002-competency-criteria-model.rst: Decision 3 for the profile's columns, the seeded all-null-scope system default, the check constraint, andscope_codebeing internal-only; Decision 2's closing boundaries for pagination on list APIs; Decision 7 for archive-only retirement. - ADR 0003,
docs/openedx_learning/decisions/0003-competency-criteria-versioning.rst, Decision 4: the pinned definition of a profile being in use. - ADR 0001,
docs/openedx_learning/decisions/0001-competency-criteria-location.rst: why CBE code sits atsrc/openedx_learning/applets/cbe/, and how the umbrella app aggregates its applets. docs/openedx_content/decisions/0010-merge-authoring-apps-into-openedx-content.rstfor the applet convention and umbrella aggregation, withsrc/openedx_content/api.pyas the working example.docs/openedx_content/decisions/0003-identifier-conventions.rstforid,uuid, andkey.- Prior art for a read-path list endpoint:
src/openedx_tagging/rest_api/v1/views.py,serializers.py,permissions.py, andurls.py, plussrc/openedx_tagging/rest_api/paginators.py; andsrc/openedx_tagging/rules.pyfor the taxonomy-administrator predicate and the permission-registration pattern. - #613 and #641 land the
CompetencyRuleProfilemodel. #613 seeds the system-default row via a data migration, validates the rule payload at the model layer, adds the profile's retired flag, and registersopenedx_learningin the installed apps and the layering contract. This ticket depends on all of it. - #664 established the CBE REST package, the app-root URL configuration, the applet's public API module, and the mount at
api/cbe/rest_api/. - #759 accepts a rule profile identifier from a caller, which is why this endpoint exposes one.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.