Skip to content

Alezconsultant/796 blocked courses - #915

Open
alezconsultant wants to merge 1 commit into
openedx:mainfrom
alezconsultant:alezconsultant/796-blocked-courses
Open

alezconsultant wants to merge 1 commit into
openedx:mainfrom
alezconsultant:alezconsultant/796-blocked-courses

Conversation

@alezconsultant

Copy link
Copy Markdown
Contributor

Description

Implements #796: a read-only endpoint that lists the courses where a competency
can't get a new CompetencyCriterion, because a taxonomy ancestor or descendant of it already has
one there.

GET /api/cbe/v1/competencies/<int:tag_id>/blocked-courses/[?course_keys=<key>,<key>]

Each row carries the blocked course's key and the conflicting tag's id and value:

{"count": 1, "next": null, "previous": null, "results": [
  {"course_key": "course-v1:Org+Course+Run", "conflicting_tag_id": 12, "conflicting_tag_value": "Critical Thinking"}
]}

This PR is built on top of #875 (pr), which provides get_courses_with_relative_criteria and the
tagging hierarchy helpers.

CompetencyReadPermission is copied verbatim from #878 (#681), so this PR doesn't depend on
#878. The two copies are identical, so whichever PR merges second reuses the class from the
first, and the merge conflict there resolves to the same lines.

Changes

  • src/openedx_learning/applets/cbe/api.py: adds get_blocked_courses(tag, course_keys=None)
    (UNSTABLE) and the BlockedCourse dataclass. It calls [BE] Add Competency hierarchy dominance validation to Competency Criteria Creation #666's get_courses_with_relative_criteria
    without a course, so the read and the write-side rejection share one query and always agree on
    which courses are blocked and which tag is named. That includes feat: enforce competency-hierarchy dominance for criteria #875's handling of archived rows:
    an archived criterion, or one under an archived group, blocks nothing in either place.
  • rest_api/v1/views.py, serializers.py, urls.py, rest_api/paginators.py: adds
    CompetencyBlockedCoursesView, BlockedCourseSerializer,
    CompetencyBlockedCoursesQueryParamsSerializer, and CompetencyBlockedCoursesPagination
    (page size 100, max 500, pinned like CompetencyRuleProfilePagination). Authentication and
    permission match feat: add GET competency criteria-groups endpoint #878's criteria-groups view.
  • rest_api/v1/permissions.py: adds CompetencyReadPermission, identical to feat: add GET competency criteria-groups endpoint #878's.
  • Tests: one endpoint test per acceptance-criteria scenario, including both the grandparent and the
    grandchild direction, plus API-level tests and the edge cases listed below.

Testing instructions

Automated: pytest tests/openedx_learning/applets/cbe tests/openedx_tagging/test_api.py --no-cov.

Manual, against a Tutor devstack with this branch's openedx-core mounted. Mount the CBE API in
your local cms/urls.py with path('api/cbe/', include('openedx_learning.urls')), run
tutor dev exec cms ./manage.py cms migrate, and restart CMS. Log in as a staff user and get a JWT
Authorization header, then use three real course keys as Course X, Y and Z.

  1. Create a competency taxonomy (POST /api/content_tagging/v1/taxonomies/ with
    "taxonomy_type": "competency") with the tags "Cognitive Skills" > "Critical Thinking" >
    "Inference", plus "Analysis" under "Critical Thinking" as a sibling of "Inference".
  2. Create criteria with POST /api/cbe/v1/competencies/<tag_id>/criteria/: "Critical Thinking" in
    Course X, "Cognitive Skills" in Course Y, "Analysis" in Course Z.
  3. GET /api/cbe/v1/competencies/<inference_id>/blocked-courses/. Expect 200 with Course X naming
    "Critical Thinking" and Course Y naming "Cognitive Skills". Course Z doesn't appear, because
    "Analysis" is a sibling of "Inference".
  4. GET the same endpoint for "Critical Thinking". Expect Course Y naming "Cognitive Skills" and
    Course Z naming "Analysis". Course X doesn't appear, because a tag's own criterion doesn't
    block it.
  5. Repeat step 3 with ?course_keys=<Course X>. Expect only Course X, with count 1.
  6. Repeat step 3 with ?page_size=1. Expect count 2, one row, and a next link that returns
    the other course.
  7. Repeat step 3 with ?course_keys=not-a-key and with ?course_keys=. Expect 400 for both.
  8. GET the endpoint for tag_id=999999. Expect 404.
  9. Disable the taxonomy and GET the endpoint as a non-staff user. Expect 403.

Other information

  • Public API: one new UNSTABLE function on openedx_learning.api. The change is purely additive.
  • BlockedCourseSerializer and the query-params serializer carry
    # pylint: disable=abstract-method, the same as the plain Serializer subclasses in
    openedx_tagging and openedx_content.
  • AI usage: implemented with Claude Code (Claude Opus 5.5), then reviewed and tested by the author.

Related to #796

🤖 Generated with the help of Claude Code

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Oct 28, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @alezconsultant!

This repository is currently maintained by @axim-engineering.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

@github-project-automation github-project-automation Bot moved this to Needs Triage in Contributions Oct 28, 2026
@alezconsultant
alezconsultant force-pushed the alezconsultant/796-blocked-courses branch from 2e373c5 to f36c0a2 Compare October 28, 2026 15:48
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Ready for Review in Contributions Oct 28, 2026
Adds GET api/cbe/v1/competencies/<tag_id>/blocked-courses/, which lists
the courses where a taxonomy ancestor or descendant of the competency
already has a criterion, so authoring can gray them out before the
create-criterion endpoint rejects them. An optional comma-separated
course_keys parameter narrows the list, and the result is paginated.

get_blocked_courses() reuses get_courses_with_relative_criteria(), so this
read and the write-side check always agree. CompetencyReadPermission is
copied verbatim from the criteria-groups endpoint's PR.

Related to openedx#796

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alezconsultant
alezconsultant force-pushed the alezconsultant/796-blocked-courses branch from f36c0a2 to 6f88a96 Compare November 2, 2026 13:56

@tbain tbain left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice, I appreciate how concise this implementation is; I feel like it could have gotten kind of bloated considering the problem statement.

I had one human nit about a comment, though T3 (Claude/Fable+GPT/Sol Astra) found a couple of issues that all seem relevant to address;


Review Details

Prerequisites, items left to other tickets, what passed, and the action list

Prerequisites (skip if you know the codebase)

The models. A Tag belongs to a Taxonomy; a taxonomy whose rows are competencies is a CompetencyTaxonomy. A CompetencyCriteriaGroup is a node in a per-competency tree: a root group for the tag, a course-level group under it that carries a course foreign key to a CourseRun, and leaf groups under that whose course is null and which belong to the course through their parent. A CompetencyCriterion hangs off a leaf group and points at an ObjectTag, which tags a subsection in a course. "Live" means the row and every group above it have archived=False (source: src/openedx_learning/applets/cbe/models/criteria.py and ADR 0002 in docs/openedx_learning/decisions/).

The dominance rule. Ticket #666 (merged as PR #875) forbids a course from holding live criteria for two tags where one is a taxonomy ancestor of the other. The function get_courses_with_relative_criteria(tag_ids, course=None) in applets/cbe/api.py returns, for a set of tag ids, every CourseRun that already has a live criterion on one of them, mapped to the lowest-id such tag. The write path calls it with one course to reject a new criterion. This PR calls it without a course to list every blocked course. Ticket #796 calls this endpoint the "read-only counterpart" of #666.

The sibling endpoint. CompetencyCriteriaTreeView (ticket #681, PR #878, then PR #911) is GET competencies/<tag_id>/criteria-tree/. It already accepts a comma-separated course_keys query parameter, parsed by CompetencyCriteriaQueryParamsSerializer in rest_api/v1/serializers.py, and it rejects a repeated parameter in its view. This PR adds a second serializer for the same parameter name.

The permission. CompetencyReadPermission in rest_api/v1/permissions.py checks oel_tagging.view_taxonomy on the tag's taxonomy. In the platform, that rule grants access to any registered user for an enabled all-org taxonomy, and to org staff and course creators for their org's taxonomies.

How the route reaches Studio. openedx-platform/cms/urls.py includes openedx_learning.urls under api/, which includes the cbe routes under cbe/. The new route is therefore api/cbe/v1/competencies/<tag_id>/blocked-courses/, with no platform change.

The UNSTABLE marker. A sentence in a docstring beginning "UNSTABLE:" declares that the API may change without the deprecation process (source: applets/cbe/api.py, for example on CompetencyCriteriaTree).

The second-model adversarial reviewer. A separate review pass, run by a different model, whose task was to look for faults in the PR (unsourced; inferred from the name).

How the review tested the tests. For each acceptance scenario, the reviewer edited the code in a throwaway worktree to break the behavior, for example by dropping the descendant half of the relative set, removing the permission check, or removing pagination. The reviewer then ran the new tests and recorded which ones failed and why. A test that stays green while the behavior is broken does not count as coverage.

Things the review looked at and is leaving to other tickets

  • Criteria under deeper group trees are not seen. The helper's filter requires a leaf's immediate parent to carry a course, so a leaf under an intermediate group is skipped, and ADR 0002 anticipates deeper trees. The write path rejects such a leaf today, so the product cannot create the data, and read and write agree. Whichever ticket introduces deeper authoring will have to widen the helper's filter, which does not undo anything in this change.
  • ADR 0002 says archiving a course-level group blocks the course for that competency, and the create path does not enforce that. This behavior is already on main and is outside #796, whose user story is about the rule from #666. Only the docstring wording in the last "Small things" item is on this change.
  • Cross-org disclosure is shared with the criteria-tree endpoint, as the second decision below describes. Any fix applies to the whole family of endpoints.

What passed

The branch merges cleanly onto current main, with the one overlapping api.py hunk merging automatically. makemigrations --check reports no changes, the import linter keeps all three contracts, and the 328 tests in the two cbe test files pass both at the PR head and on the merged tree. The PR's stated command runs 579 tests and all pass. Pylint, pycodestyle, isort, and mypy are clean on the changed files, and CI is green. All 11 acceptance scenarios have an endpoint test that failed when the reviewer broke the behavior. The second-model adversarial reviewer found the permission ordering, the 404 mapping, the archived-row handling, and the page ordering solid.

Action list

  1. Add the repeated-parameter guard to CompetencyBlockedCoursesView.get and a test for it.
  2. Reuse CompetencyCriteriaQueryParamsSerializer, or add the 100-key cap and decide the blank-value behavior deliberately.
  3. Export BlockedCourse in __all__, make it frozen=True, kw_only=True, slots=True, add the UNSTABLE sentence, and construct it with keywords.
  4. Add an anonymous 401 test.
  5. Rewrite the PR title, description, and commit body to match the diff and the platform's existing URL include.
  6. Decide on the three questions: the ADR amendment, the cross-org disclosure, and branch-qualified keys.
  7. Work through the improvements and small things as you see fit.

Must fix before merge

When a client sends the course filter several times in one request, the endpoint silently uses only the last one.

Details

In CompetencyBlockedCoursesView.get, the query string is handed to the serializer as request.query_params.dict(). Django's QueryDict.dict() keeps only the last value of a key that appears more than once. The review sent ?course_keys=X&course_keys=Y with both courses blocked and got a 200 containing only Y. Swapping the order returned only X. The sibling CompetencyCriteriaTreeView guards against this: it checks request.query_params.getlist("course_keys") and returns 400 "Supply course_keys once, as a comma-separated list." A frontend that builds its query string with one parameter per course, which is a common pattern, would be told a blocked course is free, which is the one thing this endpoint exists to prevent.

Fix: copy the sibling's guard into the new view and add a test for a repeated parameter.

The new endpoint reads the course filter by different rules than the existing endpoint that reads the same filter, and only the existing one limits its size.

Details

The new CompetencyBlockedCoursesQueryParamsSerializer and the existing CompetencyCriteriaQueryParamsSerializer differ in four ways.

  • Cap. The new one has no cap on the number of keys. The sibling refuses more than MAX_COURSE_KEYS (100). The review sent 2,000 keys and 100,000 characters to the new endpoint and got 200 both times.
  • Blank values. The new one returns 400 for a blank value or a value of only commas. The sibling declares allow_blank=True, ignores stray separators, and treats a blank filter as "scope to nothing".
  • Duplicates. The new one does not deduplicate, which is harmless because the filter is a set.
  • Lint directive. The new one suppresses pylint's abstract-method warning with a directive. The sibling defines create and update methods that raise NotImplementedError.

Two endpoints under one prefix now give one parameter name two contracts, and only one of them is bounded.

Fix: the simplest option is to reuse CompetencyCriteriaQueryParamsSerializer. A blank course_keys then yields an empty list and an empty page, which is coherent. If the 400 on a blank filter is a deliberate choice, keep the new serializer, add the cap, and say in the view docstring why blank is an error here.

The row type returned by a public function is not exported from the public API, and it lacks its neighbor's frozen dataclass settings and UNSTABLE marker.

Details

get_blocked_courses is added to __all__ in applets/cbe/api.py, and openedx_learning/api.py re-exports that module with a star import, so platform code reaches openedx_learning.api.get_blocked_courses. BlockedCourse is not in __all__, so openedx_learning.api.BlockedCourse does not exist. A caller who wants to annotate list[BlockedCourse] must import from the applets path that the package's own urls.py docstring says is meant to stay internal. The PR's own tests do exactly that. The dataclass is also a plain mutable @dataclass with no UNSTABLE sentence, while its neighbor CompetencyCriteriaTree in the same module is declared @dataclass(frozen=True, kw_only=True, slots=True) and carries the UNSTABLE marker.

Fix: add BlockedCourse to __all__, match the neighbor's decorator arguments and marker, and construct it with keyword arguments where get_blocked_courses builds rows.

No test checks that an anonymous caller is refused.

Details

The review removed IsAuthenticated from the view's permission classes and all 30 new tests still passed. Only a throwaway probe showed that an anonymous request gets 401 today. Every sibling view in test_views.py has such a test, for example test_anonymous_request_is_401_and_discloses_nothing for the criteria tree, and the maintainer asked for one when reviewing PR #878.

Fix: add one test that an unauthenticated GET returns 401 and that the body has no results key.

The pull request title, description, and commit message describe a different diff and setup than the one submitted.

Details
  • The Changes list says rest_api/v1/permissions.py adds CompetencyReadPermission. The diff touches seven files and that is not one of them, because PR #878 merged on 2026-10-27 and the class is already on the base commit.
  • The paragraph about the class being "copied verbatim from #878" and "whichever PR merges second" is stale for the same reason, and the commit message repeats it.
  • "Built on top of #875 (pr)" has a stray "(pr)".
  • The manual-testing preamble tells the tester to add path('api/cbe/', include('openedx_learning.urls')) to cms/urls.py. openedx-platform already includes openedx_learning.urls under api/, so following the instruction would mount the API a second time at api/cbe/cbe/.
  • The PR title is the branch name. Merged PRs in this repository use a conventional-commit sentence, and the commit subject feat: add blocked-courses endpoint for a competency is the right title.

The maintainer asked on #875, #878, and #911 to bring the description up to date before merge.

Decisions to make or write down

The rule that blocks these courses, and the fact that this endpoint shares the write side's lookup, are recorded in no architecture decision record.

Details

No file under docs/ mentions the rule. It lives in ticket #666 and in code. Ticket #666 justified the rule by saying there is no defined precedence between a parent's own criterion and a rollup from a child's. A commit now on main (136bcd0) amends ADR 0002 Decision 6 to define exactly that precedence, "the better of two inputs". This PR makes the rule visible to authors, which makes it costlier to change later.

The reviewer suggests a Proposed amendment to ADR 0002, outside this PR, stating five things: the rule, that the course comes from the leaf's parent group, that archived rows do not block, that the check runs at creation time only, and that the read-side hint shares the write-side lookup so the two cannot disagree. This is a question for the maintainers, not a blocker.

Any registered user can list, across the whole instance, the courses where a competency's relatives are used, which the sibling endpoint already allows.

Details

The permission is the taxonomy view rule, which in the platform passes for any registered user on an enabled all-org taxonomy, and the response is not filtered by any course or org permission. The sibling criteria-tree endpoint already discloses each criterion's course key and subsection id under the same rule, so this PR adds no new class of exposure. If that exposure is unwanted, the fix belongs to the whole family of endpoints, not this one.

A course key that carries a branch or version never matches here, although it matches on the sibling endpoint, and it is unconfirmed whether any client sends such keys.

Details

get_blocked_courses tests membership with Python equality on CourseKey objects, and course-v1:O+C+R+branch@draft is not equal to course-v1:O+C+R. The sibling criteria-tree endpoint resolves course_keys through a database lookup, where the opaque-keys field strips branch and version, so it matches. Sending ?course_keys=course-v1:O+C+R+branch@draft returns an empty page here and the course on the sibling. Studio URLs carry plain course ids, so whether a client would ever send a qualified key is unconfirmed. This is a question rather than a defect. The second-model adversarial reviewer found it.

Fix, if you want them to match: normalize each key with for_branch(None).version_agnostic() in the serializer, or resolve through get_course_run_ids as the sibling does.

Improvements worth doing

The docstring of the main lookup function leaves out three behaviors that callers can observe.

Details

Three behaviors are visible to callers and documented only on the callee or in tests. When several relatives claim one course, the lowest-id relative is named. The requested tag's own criteria never block a course. Requested course keys that match no blocked course are dropped.

Fix: say in the get_blocked_courses docstring what callers can observe but cannot read from the signature. One sentence covers all three.

The endpoint loads every conflicting row on the instance even when the request names only a few courses.

Details

get_blocked_courses calls get_courses_with_relative_criteria without a course and filters in Python. A request for one course with page_size=1 therefore still loads every conflicting group row on the instance, joined to its tag, parent, and course run. The query count is constant and the ticket's own description accepts this as response-shaping only, but the maintainer raised the same objection on PR #911 for the sibling.

Fix: consider extending the helper to accept a set of courses, so both endpoints can scope the query in the database.

The view-level pagination tests do not check the order of results.

Details

The two view pagination tests compare sets of keys. With the sort removed, only the API-level sort test fails. Ordering is what makes "none repeated, none skipped" true across separate page requests.

Fix: compare the concatenated keys from all pages with a literal ordered list.

One sort test computes its expected value the same way the code does.

Details

test_get_blocked_courses_sorts_rows_by_course_key asserts keys == sorted(keys), which recomputes what the code does.

Fix: build the expected list from the three known course codes.

Two API-level tests repeat behavior that another function's tests already cover.

Details

test_get_blocked_courses_ignores_an_archived_claim and test_get_blocked_courses_reports_one_row_per_course_naming_the_lowest_id_relative assert behaviors that get_courses_with_relative_criteria already has tests for in the same file.

Fix: drop the two tests and keep the view-level archived test as a wiring check.

Nine scenarios are tested at both the API layer and the view layer, although either layer fails on the same breaks.

Details

The API suite repeats ancestor, descendant, grandparent, grandchild, sibling, unaffected course, narrowing, empty result, and archived. PR #875 split by concern: API tests for the function's own branches, view tests for the acceptance scenarios.

Fix: at the API layer, keep the union of ancestors and descendants, narrowing, sort, row shape, and the early return.

Four direction tests in each test file differ only in their data.

Details

Ancestor, descendant, grandparent, and grandchild differ only in which tag holds the criterion and which tag is requested.

Fix: parametrize the four tests in each file. The repository already uses pytest.mark.parametrize.

It is undecided whether an early return that only saves one query should stay, since removing it fails no test.

Details

get_courses_with_relative_criteria already returns an empty mapping for an empty iterable, and deleting the if not relative_ids: return [] branch leaves every test green. The branch saves one query. If it stays, note that the "tag with no relatives" test is really testing the callee.

Small things

  • One assertion in the new lookup function has no failure message, although the assertions beside it carry one.

    Details

    The assert course_run.course_key is not None in get_blocked_courses has no message. The two asserts a few lines above it in get_courses_with_relative_criteria carry the row id, and the maintainer asked for that on PR #875.

  • A pagination class docstring points at a reason without stating it.

    Details

    The CompetencyBlockedCoursesPagination docstring, "pinned for the same reason as the rule profiles'", points at a reason instead of stating it and ends in a dangling possessive. State the reason in one sentence, as the sibling class does.

  • The view docstring omits the 401 for anonymous callers and does not mention that an out-of-range page also returns 404.

    Details

    The view docstring lists 400, 403, and 404. An out-of-range page also gives 404 (Django REST framework's InvalidPage), and an anonymous caller gets 401.

  • No test pins the default page size.

    Details

    The review set the default page size of 100 to 10 and every test passed. The maximum is covered indirectly. A test of a constant is borderline, so this is a note.

  • One assertion in a test cannot fail independently of the assertion after it.

    Details

    In test_blocked_courses_ignores_an_unaffected_course, the not in assertion cannot fail independently of the equality assertion on the next line.

  • Test helpers are duplicated across the two test files, and one helper is used inconsistently.

    Details

    make_skill_family is identical in both test files. make_leaf_criterion is copied into test_views.py while test_api.py already has it. The new course_key() helper is used in three places while other new lines read course_run.course_key directly.

  • Two lint-suppression directives were added, and the project's rules allow these only with explicit approval.

    Details

    The change adds two # pylint: disable=abstract-method directives. The project's instructions say a suppression directive needs explicit approval. The module has precedent, and the nearest sibling avoids it by defining create and update. Reusing the sibling serializer removes one.

  • A stability note on the view words its phrase differently from the sibling view.

    Details

    The UNSTABLE sentence on the view says "this shape"; the sibling view says "this response shape".

  • Two docstrings claim more than the endpoint reports about which courses cannot get a new criterion.

    Details

    The docstrings on get_blocked_courses and the view say the courses "can't get a new criterion". That overstates: the endpoint reports only the ancestor-or-descendant conflict, and ADR 0002 uses "blocks that course" for a different case, an archived course-level group. Say "where an ancestor or descendant already has a live criterion".


class CompetencyBlockedCoursesView(generics.GenericAPIView):
"""
GET-only. List the courses where a competency can't get a new criterion, paginated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this description is a little vague; "where a competency can't get a new criterion," could be clarified as to why it can't get a new Criterion? I think the next paragraph kind of touches on it, but I feel like this would be the place to concisely explain the 'why'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Ready for Review

Development

Successfully merging this pull request may close these issues.

3 participants