feat: add competency criteria models for CBE authoring layer - #800
jesperhodge wants to merge 57 commits into
Conversation
|
Thanks for the pull request, @jesperhodge! This repository is currently maintained by 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 approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo 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:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere 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:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
| OR = "OR", _("Or") | ||
|
|
||
|
|
||
| def validate_rule_payload(rule_type: str, payload: Any) -> None: |
There was a problem hiding this comment.
I don't like payload: Any, there should be a type for the dict.
And I want to validate against that type.
| .. no_pii: | ||
| """ | ||
|
|
||
| # Set at from_db() time to the scope this row had when it was loaded from the database, so |
There was a problem hiding this comment.
Comment too hard to understand
| # from_db() is a classmethod, so it sets this through a local `instance` variable rather than | ||
| # `self`, which pylint's protected-access check can't tell apart from reaching into another | ||
| # object's internals. | ||
| loaded_scope: tuple[int | None, int | None, int | None] | None = None |
There was a problem hiding this comment.
What is loaded_scope and why is it a tuple of ints?
| Organization, | ||
| null=True, | ||
| blank=True, | ||
| on_delete=models.PROTECT, |
There was a problem hiding this comment.
organization & course on_delete should also be CASCADE, with Python logic elsewhere ensuring that no learners are linked to this (if they are linked, organization / course can still be deleted but rule profile stays.)
There was a problem hiding this comment.
I think this is not quite right. CASCADE should result in archival, not deletion.
Question to flag for later: what should happen if someone actually wants to modify (or rather, delete and then replace) a rule profile? Assuming this gets archived, do the archived ones still need to be unique? In that case they are never modifiable and we need some hard delete or overwrite mechanism, I guess.
There was a problem hiding this comment.
PROTECT is no good. The org delete shouldn't be blocked. Instead, use models.SET() to run code to archive the rule_profile, but only if no learners are connected.
| CourseRun, | ||
| null=True, | ||
| blank=True, | ||
| on_delete=models.PROTECT, |
There was a problem hiding this comment.
This also needs to be CASCADE but then it should stay if there are any actual learner's already connected to the mastery. Same as elsewhere
| """Capture the scope this row had when loaded, so clean()/save() can detect an edit to it.""" | ||
| instance = super().from_db(db, field_names, values) | ||
| # field_names holds attnames (e.g. "organization_id"), not field names. Only capture when |
There was a problem hiding this comment.
I have no idea what this is supposed to mean. Clarify what the intent is here.
There was a problem hiding this comment.
Why do we need to override from_db at all?
| ) | ||
| return instance | ||
|
|
||
| def _check_scope_immutable(self) -> None: |
There was a problem hiding this comment.
This seems pretty complicated code. Can it be simplified following KISS principle?
| help_text=_("The profile this criterion uses by default. Null only when overrides are set instead."), | ||
| ) | ||
| rule_type_override = models.CharField(max_length=32, choices=RuleType, null=True, blank=True) | ||
| rule_payload_override = models.JSONField(null=True, blank=True) |
There was a problem hiding this comment.
Use attrs, as in my other comment.
| null=True, | ||
| blank=True, | ||
| db_column="competency_rule_profile_id", | ||
| on_delete=models.PROTECT, |
There was a problem hiding this comment.
I think this is okay because rule profiles should be archived not deleted in general.
| ) | ||
| uuid = immutable_uuid_field() | ||
|
|
||
| history = HistoricalRecords(excluded_fields=["scope_code"]) |
There was a problem hiding this comment.
I wonder if the history and the archived flag somehow conflict with each other.
Also I wonder about this immutability idea anyway: if this should be immutable why a history?
Maybe immutability is not a clear concept in the issue. This will need further clarification from the architect.
| # ============================================================================================== | ||
|
|
||
|
|
||
| def test_group_parent_cascade(tag: Tag) -> None: |
There was a problem hiding this comment.
Test names are bad. They should all state the expected behavior. I don't care if that makes them long. E.g. "test_criteria_group_deletion" is bad, while "test_delete_criteria_group_cascades_to_child_groups" is good.
There was a problem hiding this comment.
Here's some reading for proper naming of tests. https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-best-practices
|
|
||
| def test_group_parent_cascade(tag: Tag) -> None: | ||
| """ | ||
| Deleting a CompetencyCriteriaGroup cascades to any child group referencing it via `parent`: |
There was a problem hiding this comment.
At least some of the tests need to be a bit more integrative. In that: sure, we have tested that the cascade is there, but it's not clear why. There should be at least some tests that look at the actual bad outcome that we want to avoid: for example, do we suddenly have orphaned child groups that do not serve any purpose?
|
|
||
|
|
||
| # ============================================================================================== | ||
| # Transitive deletion tests required by #641's Deletions criteria: deleting an oel_tagging.Tag, |
There was a problem hiding this comment.
Too unclear. I have no patience to decipher what this comment means. Either it's clear at a glance or it's useless.
Implements the authoring and definition half of the CBE data model from ADR-0002: CompetencyCriteriaGroup (internal AND/OR nodes), CompetencyRuleProfile (reusable scoped evaluation defaults) and CompetencyCriterion (leaf nodes). Also adds the taxonomy_overrides_org column that PR openedx#712 left off CompetencyTaxonomy. CompetencyRuleProfile.scope_code is a generated, never-null column with a plain unique constraint. SQL never treats two NULLs as equal, so a unique constraint over the three nullable scope columns would accept two rows with the same scope, and the conditional UniqueConstraint that would normally fix that compiles to a partial index MySQL does not support. Both structural invariants are database check constraints rather than clean() checks, since DRF serializers, QuerySet.update() and bulk_create() never call full_clean(). Payload shape validation stays in clean(), per the issue. Every new foreign key is on_delete=PROTECT with a TODO(openedx#799) comment. That is a fail-closed placeholder, not a per-key decision; openedx#799 sets the real values once openedx#655 lands. openedx_catalog joins .importlinter's root_packages and the src_layering contract, since CompetencyCriteriaGroup.course is the first foreign key from openedx_learning into that app. django-simple-history moves into base.in: it was only ever a transitive dependency of edx-organizations, and setup.py builds install_requires from base.in. Refs openedx#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openedx#655 closed with an approved design and openedx#799 is now closed as superseded, so both halves of the nine repeated TODO comments were false: openedx#799 does not own the on_delete question, and no follow-up ticket will set "the real" per-foreign-key values. Replaces those nine identical comments with one explanation in the module docstring, which also records the open question openedx#655's design creates for CompetencyCriteriaGroup.tag and CompetencyCriterion.object_tag: that design keeps openedx_tagging ignorant of CBE and promises a plain hard delete for a tag no learner holds mastery against, which PROTECT turns into a ProtectedError whenever an author's criteria tree references the tag and nobody has been graded yet. The PROTECT values themselves are unchanged. They remain the fail-closed default until openedx#655's reviewers settle the question. Refs openedx#641, openedx#655 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openedx#641 requires at least one test per foreign key asserting that deleting the referenced row matches what the field declares. All nine are PROTECT, so all nine assert ProtectedError, and each inspects ProtectedError.protected_objects rather than only the exception type: a single delete can trip several protected relationships, so a bare pytest.raises would not prove which foreign key did the protecting. Two cases needed isolating to avoid passing for the wrong reason. CatalogCourse.org is itself PROTECT, so the organization test uses an organization with no catalog course attached. Tag.taxonomy is CASCADE, so the competency_taxonomy test omits the tag and group fixtures. A tenth test pins the open openedx#655 question in executable form: deleting a CompetencyTaxonomy whose tag carries a criteria tree raises ProtectedError today, though that design promises the delete succeeds when no learner status exists. It is the test that has to change if the reviewers move CompetencyCriteriaGroup.tag to CASCADE, and says so. Refs openedx#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openedx#655 decided the on_delete question on 2026-09-02, so the four foreign keys between definition tables become CASCADE: CompetencyCriteriaGroup.parent, CompetencyCriteriaGroup.tag, CompetencyCriterion.group and CompetencyCriterion.object_tag. The other five stay PROTECT and are now final. Deleting a Tag nobody holds mastery against has to succeed, and openedx#655's design forbids openedx_tagging from knowing CBE exists, so the tagging-side path cannot clear the criteria tree first. CASCADE lets the delete take the tree with it. parent and group need it too, because Django's collector looks up referencing rows in the database rather than in the set it has already collected, so a parent and child reached in one batch would trip PROTECT and abort the walk partway down. This does not weaken ADR-0002 Decision 7. The four CASCADE links are what carries the collector down to the PROTECT that enforces it, on openedx#642's Student*Status foreign keys one and two levels below the tag, which Django reaches only by walking CASCADE edges. Migration 0002 is edited in place rather than gaining an AlterField, since it is unmerged. The delete tests are reworked accordingly and extended with the transitive cases: a tag delete cascading a whole tree, a group delete at depth taking its descendants and their criteria, and a taxonomy delete reaching through tag to group to criterion. The matching "raises ProtectedError when a learner status exists" halves need openedx#642's tables and belong to that slice, which a comment in the test file records. One cascade test also asserts django-simple-history writes a history_type='-' row per removed row, so the cascade is not silent for audit. Refs openedx#641, openedx#655 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dx#641 Four changes, all from openedx#641's revision. CompetencyRuleProfile.competency_taxonomy becomes CASCADE, making the split five CASCADE and four PROTECT. The reason is a requirement rather than a mechanism: a rule profile must never be why a taxonomy delete fails. Once taxonomy-scoped profiles exist, deleting a taxonomy has to be blocked only when learner data is connected to it, and that check belongs in Python at the application layer, the way openedx#655 settled it for every other record type. PROTECT would push the decision into the database, which cannot tell the two cases apart. Nothing changes behaviorally in MVP, because the only profile is the system default and its three scope columns are all null. openedx_content and openedx_catalog become independent siblings in the src_layering contract rather than separate ranks. A layers contract is a strict total order, so ranking them asserted both that openedx_content may import openedx_catalog and that openedx_catalog may never import openedx_content. src/openedx_catalog/ARCHITECTURE.md records that direction as explicitly undecided, so the sibling form, which forbids imports both ways, asserts only what is settled. The Meta.db_table override is dropped, so the leaf table is Django's default openedx_learning_competencycriterion. ADR-0002 Decision 4's heading names a domain concept rather than instructing a rename, and no model anywhere in src/ overrides db_table. The competency_taxonomy delete test becomes a cascade test to match. Refs openedx#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the hand-rolled key-walking in validate_rule_payload with one attrs class per rule type, using the modern `from attrs import define, field` style already used in openedx_tagging and openedx_content. attrs is already a declared dependency, so nothing changes in requirements. The spec class is now the definition of the shape: constructing it does the checking, and the expected key set is derived from it via attrs.fields() rather than repeated in a literal. Adding MasteryLevel later is one class plus one registry entry. The field validators stay hand-written rather than using attrs.validators.in_(), because that helper's default message dumps the whole Attribute repr into the error, which a course author would see in the Django admin. Key errors are raised before construction for the same reason: Python's own TypeError names the offending key but leaks "GradeRule.__init__()" along with it. validate_rule_payload is now also called from save() on both models. clean() is reached only via full_clean(), so objects.create() and instance.save() previously bypassed payload validation entirely; this closes both. QuerySet.update(), bulk_create() and DRF serializers remain uncovered, because none of them builds or saves a model instance, and both model docstrings say so rather than implying more. CourseRun.save() is the existing precedent in this repo for validating in save(). One consequence, split rather than papered over: a criterion with rule_type_override set and no payload now raises ValidationError from save() before the check constraint sees it, so that case moves out of test_criterion_profile_xor_override_constraint into its own test. The other three invalid states still reach the constraint and still raise IntegrityError. The seed data migration is unaffected: apps.get_model() returns a historical model that does not carry the custom save(). Refs openedx#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add simple_history to INSTALLED_APPS in the test and dev settings. The CBE models declare HistoricalRecords(), and while the historical models are built under openedx_learning's own app label and so work without the entry, its absence breaks SimpleHistoryAdmin's history views, its template tag libraries and the populate_history/clean_old_history/clean_duplicate_history commands. The package ships no AppConfig and registers no system check, so nothing warns. Rank openedx_content above openedx_catalog in the src_layering contract rather than making them independent siblings. The sibling form forbids imports in both directions, including the one 0007-pathway-catalog-content-split.rst requires: "openedx_content knows about openedx_catalog, never the reverse." Ranking asserts only the settled half, that catalog never reaches up into content, and does not have to be loosened when pathway content lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scope_code becomes an ordinary column written in save(), null when the profile is archived, keeping the plain UniqueConstraint and adding a CheckConstraint tying the two. This fixes three defects. An archived profile used to occupy its scope's unique slot forever, so no replacement could ever be created for that scope; SQL never treats two NULLs as equal, so archived rows now share a scope freely while exactly one live row holds it, identically on MySQL and SQLite. A database GeneratedField was also rewritten whenever Django's collector nulled a nullable scope foreign key before deleting, which it does on any backend where can_defer_constraint_checks is false, colliding with the seeded default row on MySQL while passing on SQLite. A plain column is not rewritten by that update. It also avoids Django never populating a GeneratedField in memory on MySQL. Two on_delete values change, per ADR-0002 Decision 7 as amended by b5fae6b. CompetencyRuleProfile.course becomes CASCADE, which that amendment requires when it says a profile is deleted with "a taxonomy or course" it is scoped to. CompetencyCriteriaGroup.course becomes CASCADE for the same stated reason: a course is only hard-deleted once nothing beneath it needs protecting, so a course-scoped criteria tree is safe to remove with it rather than blocking the delete. Both deviate from openedx#641, which lists them as PROTECT. Drop the loaded_scope cache and the from_db() override; _check_scope_immutable() now always reads the persisted scope, on self._state.db so a non-default alias is not silently skipped. save() calls full_clean() on both models instead of duplicating a hand-picked validation list that could drift from clean(). Move RuleType, the payload spec classes and the parser to rule_payloads.py, so the JSON schema is not trapped behind a module importing five models, and have it return the frozen GradeRule rather than discarding it. Both models derive their choices from the payload-spec registry, so a rule type can never be offered to an author and then rejected on save. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dge cases Add a test per acceptance criterion, plus the cases the previous per-foreign-key shape could not reach: a taxonomy or course run deleted with a scoped rule profile, two taxonomies deleted together, an archived profile's scope being reused, and an ObjectTag delete leaving a childless group behind. Add test_criteria_trees.py for whole-tree deletion, so a test proves the bad outcome is avoided rather than only that a cascade fired: it builds a root/branch/grandchild tree with criteria at two levels and a mix of profile-assigned and override criteria, deletes in the middle, and asserts exactly which rows survive. Run the deletion paths under MySQL's collector semantics while still on SQLite, by setting can_defer_constraint_checks to False. That is what makes this class of bug visible in the fast local suite instead of only in the MySQL CI job. Rename every test so the name states the expected behavior rather than the mechanism, and move the fixtures duplicated across both files into conftest.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a6a8e76 to
5380a64
Compare
Decision 3 and Rejected Alternative 6 described scope_code as a database-generated, never-null column; the code has always shipped a plain, nullable-while-archived column instead. Amend both to match, and add the on_delete containment rationale and the taxonomy-delete known limitation to Decision 7, so the reasoning that was living only in inline comments and test docstrings has one authoritative home. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops the middle-node comparison clause; the sentence explaining the root-to-grandchild delete is what exercises the recursion stands on its own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ADR-0002 Decision 3 stores an evaluation rule as a rule_type plus a JSON rule_payload whose shape that type defines, rather than as fixed op/value/scale columns, so a future rule type can add its own fields without a migration. The cost of JSON is that nothing enforces the shape, so this adds the validator the two criteria models will call from clean(). Grade is the only supported type. Its value is a fraction from 0.0 to 1.0, not a number out of 100, which is the mistake an author is most likely to make, so the out-of-range message names the convention rather than only rejecting the value. RuleType declares exactly the types that have a payload spec, so a type can never be offered as a choice without being saveable. Refs openedx#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GradePayload declares the stored shape of a Grade rule_payload, so that a site holding a validated payload can be annotated with it and mypy checks payload literals against the declared field names and types. The runtime validation is unchanged: a TypedDict value is a plain dict, so the JSONField round-trip and the seeded default row are unaffected. This also removes two duplications. The expected key set now comes from the TypedDict rather than a hand-written frozenset repeating the keys _validate_grade_payload reads, and the comparison operators are declared once as a Literal instead of again in a separate set. validate_rule_payload takes `object` rather than `Any`. Its argument is untrusted JSON out of a JSONField and cannot be narrowed at the signature, but `object` makes mypy enforce the isinstance guard that `Any` allowed a caller to skip unchecked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renames GradePayload/_validate_grade_payload to GradeRulePayload/ _validate_grade_rule_payload, rewrites the RuleType and bool-guard docstrings to lead with consequence over mechanism, tightens the wrong-keys test to assert exact message phrasing instead of loose substrings, and trims two test docstrings, per mgwozdz-unicon's review. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Spells out ADR-0002 Decision 3's actual constraints (op, value range, scale) instead of only pointing at the ADR, per review nit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A reusable evaluation rule scoped to at most one of an organization, a course or a taxonomy, plus the one row scoped to none of them, which is the rule every criterion falls back to. A deployment that adds no profiles of its own gets an 80% threshold. See ADR-0002 Decision 3. Uniqueness per scope cannot be a plain constraint over the three nullable scope columns, because SQL never treats two NULLs as equal, and it cannot be a conditional constraint either, because MySQL has no partial unique indexes and Django silently skips creating one there. So the scope collapses into a derived scope_code column with one unconditional unique constraint. scope_code goes null while a profile is archived, which frees that scope for a replacement; the three scope columns are never cleared, so nothing is lost. Scope is immutable after creation, so criteria already resolved to a profile are never silently re-scoped. Deleting a scope owner takes the profile scoped to it, so course and competency_taxonomy cascade, per ADR-0002 Decision 7. organization does not: an Organization is not a competency definition record, and edx-organizations retires one by clearing its active flag rather than deleting the row, so PROTECT there refuses a delete that should not be happening. Refs openedx#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These six tests only need CompetencyRuleProfile, so they belong with the on_delete values this PR already declares, not in the cross-model part 8 suite they were drafted alongside.
…nction rule_payload's help_text told the reader to "see validate_rule_payload for the shape it must match". help_text renders in the Django admin and the DRF browsable API, so that is user-facing text pointing at an internal Python function the reader cannot open, which is what rule_payloads.py's own docstring forbids for the error messages beside it. It now states the shape. 0004 is amended in place because help_text is part of a field's deconstructed kwargs; makemigrations --check confirms no new migration is needed. Also cover the seeded system-default row against the payload contract. 0005_seed_default_rule_profile writes its payload as a literal and cannot check it: a historical migration must not import rule_payloads, and apps.get_model() returns a model without the custom clean(). Nothing else reconciled that literal with the validator, so tightening _validate_grade_payload would have left every deployment's default row invalid with no failing test. The new test is the one place the two meet. The neighbouring docstring credited the seeding to migration 0003, which is competencycriteriagroup. It is 0005. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trim module docstrings to one line, drop DB-introspection-style tests with no precedent elsewhere in the codebase (schema columns, the scope_code unique index), rewrite the columns test as a DB round-trip instead, merge two redundant archive/replace tests into one, correct a stale migration number in a comment, and tighten a few test names and docstrings to match what each test actually proves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wrap a help_text string that exceeded the 120-column limit, and drop a pylint invalid-str-returned suppression that pylint now reports as unused since __str__'s -> str annotation already tells it the return type. Neither changes the string value or any runtime behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Removes an orphaned "# Schema" banner left over from an earlier review fix, and adds a test using the previously-unused default_rule_profile fixture: archiving the system-default CompetencyRuleProfile nulls its scope_code and frees the all-null scope for a fresh replacement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Neither test reads or asserts scope_code, so drop the collision-avoidance claim from the taxonomy and course-run MySQL-semantics test docstrings, and shorten the two-taxonomies test's docstring to what it actually exercises: the collector's multi-row nulling path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A leaf points at one ObjectTag, meaning one specific piece of tagged content, and takes its pass rule either from a shared CompetencyRuleProfile or from its own inline override pair. A check constraint enforces ADR-0002 Decision 4's invariant: never both, never neither. The stored rule_profile is not resolved at read time. Decision 4 assigns it at four named write events and stores the result, so a criterion that already resolved to a less specific profile is not silently re-governed when a more specific one appears later. Computing that assignment is authoring-API work and is not here. group and object_tag cascade, per ADR-0002 Decision 7: a leaf means nothing without the group above it or the content association it evaluates. rule_profile is RESTRICT rather than PROTECT. Both refuse a direct profile delete while any criterion is assigned to it, which is what makes a profile archive-only at the ORM layer. They differ once the profile is deleted as part of a larger operation: PROTECT raises for any referencing row it finds in the database, so deleting a CompetencyTaxonomy would fail naming a criterion the same operation was already about to remove, while RESTRICT ignores rows that are themselves being deleted and lets that cascade through. Refs openedx#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n test down to this PR This model completes the criteria tree, so its own on_delete values, the transitive tag/taxonomy cascades that only exist once it does, the two RESTRICT-vs-PROTECT payoff/residual scenarios, and the tree-wide integration test all belong here, not in the cross-model part 8 suite they were drafted alongside.
Applies 19 of mgwozdz-unicon's 22 review items (docstring trims, dropped CompetencyAchievementCriteria naming, removed redundant/misplaced tests, added help_text to the override fields); the docstring/test content for items 1, 11 and 15 is left as-is pending a separate decision. Also adds two tests: a cascaded CompetencyCriterion delete is recorded in history, and the profile/override check constraint holds against bulk_create(), which bypasses clean()/full_clean() entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Shortens the CompetencyCriterion class docstring per review item 1, deletes test_criterion_rule_profile_is_not_recomputed_once_a_more_specific_profile_appears per item 11, and cuts test_criterion_deletion.py's module docstring to one line per item 15, per mgwozdz-unicon's explicit request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The rule_type_override/rule_payload_override help_text added in an earlier review-feedback commit never got a matching migration; this was only caught by makemigrations --check after rebasing the stack. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… PR branch The stack on jesperhodge/openedx-core (#10, #2, #3, #4, #5, #6, #7) is where the openedx#641 code is written and reviewed. This branch only carries the stack tip's tree so that openedx#800 has something to squash-merge. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Description
This PR adds the database models that let course authors define competency-based grading criteria: what a learner has to do, and how well, to be marked competent in a given skill. It adds three new tables, one new setting on an existing table, and the migrations to create them. It's going in now so the rest of the competency-based education work (issue #641) has a solid foundation to build on.
Implements #641 .
Closes #641 .
Supporting information
Note
Implemented by an AI agent (Claude Code), with a human directing the work and reviewing the
code and decisions.
Parent ticket of 641: #613
How to review this PR
Review the stack on my fork, in the order in the table below.. Do not review this PR directly.
The seven reviewable parts live on
jesperhodge/openedx-core,each targeting the part below it, with the bottom one targeting a branch identical to upstream
main.This PR exists so that there is something on
openedx/openedx-coreto merge once the stackis approved. Its commit history is a series of merges from the stack and carries no information of
its own.
scope_codeis computed in application code and is null while a profile is archived; theon_deletevalue of every competency foreign key.django-simple-history; layersopenedx_catalogin.importlinter.CompetencyTaxonomy.taxonomy_overrides_org.CompetencyCriteriaGroup, the criteria tree's AND/OR node.RuleType,GradeRulePayload,validate_rule_payload.CompetencyRuleProfileand the seeded system default.CompetencyCriterion, the tree's leaf, plus the tree-wide tests.The design rationale, and each place the implementation deviates from #641, are recorded in the ADR
amendments in part 1 and in each part's description. Review comments belong on the part that owns
the code.
This PR's tree is identical to the stack tip. The last commit on this branch,
344745a, is amerge commit whose tree is part 7's tree at
cd5d15b:Every change to the stack is followed by one more such merge commit here, and the two SHAs above are
updated. Nothing is committed directly to this branch.
How to merge
commitlintcheck: A squash merge takes this PR's title as the commit message, so nothing non-conventional reachesmain.Other information
Alezconsultant/665 criterion endpoint jesperhodge/openedx-core#14 for [BE] Create CompetencyCriteria #665); their authors will rebase them onto the squash commit before
opening them here.
Testing
I've put a few code snippets here that the AI agents provided which allow some manual testing.
CI's tests job runs the unit test suite against MySQL 8. That run is the one that matters for the
scope_codeand deletion behavior, which differ between the two backends.Part 4 —
CompetencyCriteriaGroup, the criteria tree's AND/OR nodeBy hand, confirm the tree links work and that a parent delete cascades to its children:
Part 5 — the rule payload contract
Part 6 —
CompetencyRuleProfileand the seeded system defaultRun this suite against MySQL, not only SQLite. Issue #641 asks for it specifically, because the
scope_codeconstraint is the one thing SQLite cannot tell you the truth about:By hand, the archive-and-replace cycle is the most interesting behavior:
Part 7 —
CompetencyCriterion, the tree's leafBy hand, the invariant, which is the one a future API can most easily break:
And that the stored profile is not re-resolved at read time: