diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index 6b9f74d07..2d5847b8f 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -77,6 +77,8 @@ openedx_content.Unit: ".. no_pii:": "This model has no PII" openedx_content.UnitVersion: ".. no_pii:": "This model has no PII" +openedx_learning.HistoricalCompetencyCriteriaGroup: + ".. no_pii:": "This model has no PII" social_django.Association: ".. no_pii:": "This model has no PII" social_django.Code: diff --git a/src/openedx_learning/applets/cbe/models/__init__.py b/src/openedx_learning/applets/cbe/models/__init__.py index b70b37c9c..fe57c998e 100644 --- a/src/openedx_learning/applets/cbe/models/__init__.py +++ b/src/openedx_learning/applets/cbe/models/__init__.py @@ -3,7 +3,10 @@ """ from .competency_taxonomy import CompetencyTaxonomy +from .criteria import CompetencyCriteriaGroup, LogicOperator __all__ = [ + "CompetencyCriteriaGroup", "CompetencyTaxonomy", + "LogicOperator", ] diff --git a/src/openedx_learning/applets/cbe/models/criteria.py b/src/openedx_learning/applets/cbe/models/criteria.py new file mode 100644 index 000000000..f06adb680 --- /dev/null +++ b/src/openedx_learning/applets/cbe/models/criteria.py @@ -0,0 +1,100 @@ +""" +The CompetencyAchievementCriteria tree: CompetencyCriteriaGroup, the internal AND/OR node. + +See :ref:`openedx-learning-adr-0002` Decision 2 for the design and Decision 7 for why every +foreign key here cascades, and :ref:`openedx-learning-adr-0003` Decisions 1 and 2 for why this +model carries ``django-simple-history`` tracking and CompetencyTaxonomy does not. +""" +from __future__ import annotations + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from simple_history.models import HistoricalRecords + +from openedx_catalog.models import CourseRun +from openedx_django_lib.fields import case_insensitive_char_field, immutable_uuid_field +from openedx_tagging.models import Tag + +__all__ = [ + "CompetencyCriteriaGroup", + "LogicOperator", +] + + +class LogicOperator(models.TextChoices): + """How a CompetencyCriteriaGroup combines its child nodes.""" + + AND = "AND", _("And") + OR = "OR", _("Or") + + +class CompetencyCriteriaGroup(models.Model): + """ + An internal AND/OR node in a CompetencyAchievementCriteria expression tree. + + A single CompetencyAchievementCriteria is one root CompetencyCriteriaGroup plus all of its + descendant groups and leaf :class:`CompetencyCriterion` rows. ``logic_operator`` says how + this group's own children combine. ``ordering`` gives this group's own position among its + siblings under their shared parent, which read-time evaluation and event-driven recomputation + rely on for deterministic, short-circuiting evaluation order. A group's children can be a mix + of child groups and leaf criteria, and only CompetencyCriteriaGroup carries an ``ordering`` + field, so that mix has no total order; #641 accepts this deliberately. See ADR-0002 Decision 2. + + .. no_pii: + """ + + uuid = immutable_uuid_field() + parent = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="child_groups", + help_text=_("The parent CompetencyCriteriaGroup. Null means this group is a tree root."), + ) + tag = models.ForeignKey( + Tag, + db_column="oel_tagging_tag_id", + on_delete=models.CASCADE, + related_name="competency_criteria_groups", + help_text=_("The competency (tag) that this criteria tree evaluates mastery of."), + ) + course = models.ForeignKey( + CourseRun, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="competency_criteria_groups", + help_text=_("The course run that scopes this criteria tree for evaluation windowing, if any."), + ) + name = case_insensitive_char_field( + max_length=255, blank=True, default="", help_text=_("A human-readable label for this group, if any.") + ) + ordering = models.PositiveIntegerField( + default=0, + help_text=_( + "Deterministic sibling evaluation sequence. Used to short-circuit evaluation and to order " + "child scans during event-driven recomputation." + ), + ) + logic_operator = models.CharField( + max_length=3, + choices=LogicOperator, + null=True, + blank=True, + help_text=_( + "How this group's children combine. Null only for a group with a single child, where combining " + "logic is moot; the application layer treats null the same as OR." + ), + ) + + history = HistoricalRecords() + + class Meta: + indexes = [ + # ADR-0002 Decision 5, index 1: lookups by competency tag and course scope. + models.Index(fields=["tag", "course"]), + # ADR-0002 Decision 5 also lists an index on `parent` (index 2), but Django already + # indexes every ForeignKey column by default, so a second explicit one here would only + # cost write throughput without adding any read benefit. + ] diff --git a/src/openedx_learning/migrations/0003_competencycriteriagroup.py b/src/openedx_learning/migrations/0003_competencycriteriagroup.py new file mode 100644 index 000000000..e2f42609c --- /dev/null +++ b/src/openedx_learning/migrations/0003_competencycriteriagroup.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.16 on 2026-09-10 18:32 + +import uuid + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + +import openedx_django_lib.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('oel_tagging', '0021_remove_system_defined_add_read_only'), + ('openedx_catalog', '0001_initial'), + ('openedx_learning', '0002_competencytaxonomy_taxonomy_overrides_org'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CompetencyCriteriaGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), + ('name', openedx_django_lib.fields.MultiCollationCharField(blank=True, db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, default='', help_text='A human-readable label for this group, if any.', max_length=255)), + ('ordering', models.PositiveIntegerField(default=0, help_text='Deterministic sibling evaluation sequence. Used to short-circuit evaluation and to order child scans during event-driven recomputation.')), + ('logic_operator', models.CharField(blank=True, choices=[('AND', 'And'), ('OR', 'Or')], help_text="How this group's children combine. Null only for a group with a single child, where combining logic is moot; the application layer treats null the same as OR.", max_length=3, null=True)), + ('course', models.ForeignKey(blank=True, help_text='The course run that scopes this criteria tree for evaluation windowing, if any.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='competency_criteria_groups', to='openedx_catalog.courserun')), + ('parent', models.ForeignKey(blank=True, help_text='The parent CompetencyCriteriaGroup. Null means this group is a tree root.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_groups', to='openedx_learning.competencycriteriagroup')), + ('tag', models.ForeignKey(db_column='oel_tagging_tag_id', help_text='The competency (tag) that this criteria tree evaluates mastery of.', on_delete=django.db.models.deletion.CASCADE, related_name='competency_criteria_groups', to='oel_tagging.tag')), + ], + ), + migrations.CreateModel( + name='HistoricalCompetencyCriteriaGroup', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, verbose_name='UUID')), + ('name', openedx_django_lib.fields.MultiCollationCharField(blank=True, db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, default='', help_text='A human-readable label for this group, if any.', max_length=255)), + ('ordering', models.PositiveIntegerField(default=0, help_text='Deterministic sibling evaluation sequence. Used to short-circuit evaluation and to order child scans during event-driven recomputation.')), + ('logic_operator', models.CharField(blank=True, choices=[('AND', 'And'), ('OR', 'Or')], help_text="How this group's children combine. Null only for a group with a single child, where combining logic is moot; the application layer treats null the same as OR.", max_length=3, null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('course', models.ForeignKey(blank=True, db_constraint=False, help_text='The course run that scopes this criteria tree for evaluation windowing, if any.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='openedx_catalog.courserun')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('parent', models.ForeignKey(blank=True, db_constraint=False, help_text='The parent CompetencyCriteriaGroup. Null means this group is a tree root.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='openedx_learning.competencycriteriagroup')), + ('tag', models.ForeignKey(blank=True, db_column='oel_tagging_tag_id', db_constraint=False, help_text='The competency (tag) that this criteria tree evaluates mastery of.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='oel_tagging.tag')), + ], + options={ + 'verbose_name': 'historical competency criteria group', + 'verbose_name_plural': 'historical competency criteria groups', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddIndex( + model_name='competencycriteriagroup', + index=models.Index(fields=['tag', 'course'], name='openedx_lea_oel_tag_737416_idx'), + ), + ] diff --git a/tests/openedx_learning/applets/cbe/conftest.py b/tests/openedx_learning/applets/cbe/conftest.py new file mode 100644 index 000000000..60fefbc79 --- /dev/null +++ b/tests/openedx_learning/applets/cbe/conftest.py @@ -0,0 +1,40 @@ +"""Shared fixtures for the CBE criteria test modules.""" +import pytest +from organizations.api import ensure_organization +from organizations.models import Organization + +from openedx_catalog.models import CatalogCourse, CourseRun +from openedx_learning.models import CompetencyCriteriaGroup, CompetencyTaxonomy +from openedx_tagging.models import Tag + + +@pytest.fixture(name="organization") +def _organization() -> Organization: + """An Organization for use as a scope in these tests.""" + ensure_organization("Org1") + return Organization.objects.get(short_name="Org1") + + +@pytest.fixture(name="course_run") +def _course_run(organization: Organization) -> CourseRun: + """A CourseRun for use as a scope in these tests.""" + catalog_course = CatalogCourse.objects.create(org=organization, course_code="Python100") + return CourseRun.objects.create(catalog_course=catalog_course, run_code="Fall2026") + + +@pytest.fixture(name="competency_taxonomy") +def _competency_taxonomy() -> CompetencyTaxonomy: + """A CompetencyTaxonomy for use as a scope, and as the home taxonomy for `tag`.""" + return CompetencyTaxonomy.objects.create(name="Nursing", export_id="nursing-v1") + + +@pytest.fixture(name="tag") +def _tag(competency_taxonomy: CompetencyTaxonomy) -> Tag: + """A Tag, from `competency_taxonomy`, for use as the competency a criteria tree evaluates.""" + return Tag.objects.create(taxonomy=competency_taxonomy, value="Writing Poetry") + + +@pytest.fixture(name="group") +def _group(tag: Tag) -> CompetencyCriteriaGroup: + """A root CompetencyCriteriaGroup for `tag`, for use as a criterion's parent group.""" + return CompetencyCriteriaGroup.objects.create(tag=tag) diff --git a/tests/openedx_learning/applets/cbe/test_criteria_group.py b/tests/openedx_learning/applets/cbe/test_criteria_group.py new file mode 100644 index 000000000..9f98d9349 --- /dev/null +++ b/tests/openedx_learning/applets/cbe/test_criteria_group.py @@ -0,0 +1,179 @@ +""" +Tests for CompetencyCriteriaGroup, the internal AND/OR node of a CompetencyAchievementCriteria +tree. + +Each test name states the behavior it pins. Reading top to bottom gives the model's contract: +its columns, its tree shape, the two constraints ADR-0002 Decision 2 deliberately leaves out, +then its indexes and history. + +Delete behavior is not covered here. Nothing in this module deletes a row that another row +points at. See test_criteria_group_deletion.py, in this same change, for this model's own +`on_delete` values and the tests that exercise them. + +Fixtures live in this directory's conftest.py. +""" +import pytest +from django.apps import apps +from django.db import connection, models + +from openedx_catalog.models import CourseRun +from openedx_learning.models import CompetencyCriteriaGroup, CompetencyTaxonomy, LogicOperator +from openedx_tagging.models import Tag, Taxonomy + +pytestmark = pytest.mark.django_db + + +# --------------------------------------------------------------------------------------------- +# Schema + + +# --------------------------------------------------------------------------------------------- + + +def test_group_has_exactly_the_columns_adr_0002_decision_2_lists() -> None: + """ + CompetencyCriteriaGroup's columns are exactly the ones ADR-0002 Decision 2 lists, with + `parent`, `course`, and `logic_operator` optional and the rest required. `tag` keeps the + legacy `oel_tagging_tag_id` column name. No `archived` column yet; that arrives with #642. + """ + fields = [f for f in CompetencyCriteriaGroup._meta.get_fields() if f.concrete] + assert {f.name for f in fields} == { + "id", "uuid", "parent", "tag", "course", "name", "ordering", "logic_operator", + } + assert {f.name for f in fields if f.null} == {"parent", "course", "logic_operator"} + assert CompetencyCriteriaGroup._meta.get_field("parent").remote_field.model is CompetencyCriteriaGroup + assert CompetencyCriteriaGroup._meta.get_field("tag").remote_field.model is Tag + assert CompetencyCriteriaGroup._meta.get_field("tag").db_column == "oel_tagging_tag_id" + assert CompetencyCriteriaGroup._meta.get_field("course").remote_field.model is CourseRun + + +# --------------------------------------------------------------------------------------------- +# Tree shape, and the two constraints ADR-0002 Decision 2 deliberately leaves out + + +# --------------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "logic_operator", + [ + pytest.param(LogicOperator.AND, id="and"), + pytest.param(LogicOperator.OR, id="or"), + pytest.param(None, id="null"), + ], +) +def test_group_logic_operator_accepts_and_or_and_null_regardless_of_child_count( + logic_operator: str | None, tag: Tag +) -> None: + """ + logic_operator accepts AND, OR, or null. Nothing at the data layer constrains it by how many + children the group actually has: a group with zero children and a group with two children both + save successfully with any of the three values. See ADR-0002 Decision 2; the database cannot + see a group's future children at save time (a child's parent FK cannot point at a row that + doesn't have a primary key yet), so this is enforced nowhere at this layer, deliberately. + """ + childless = CompetencyCriteriaGroup.objects.create(tag=tag, logic_operator=logic_operator) + assert childless.pk is not None + + parent = CompetencyCriteriaGroup.objects.create(tag=tag, logic_operator=logic_operator) + CompetencyCriteriaGroup.objects.create(tag=tag, parent=parent) + CompetencyCriteriaGroup.objects.create(tag=tag, parent=parent) + assert CompetencyCriteriaGroup.objects.filter(parent=parent).count() == 2 + + +def test_a_root_group_has_a_null_parent_and_a_child_points_at_the_group_it_was_created_under(tag: Tag) -> None: + """ + A CompetencyCriteriaGroup's parent is null for a root and points at its parent for a child. + See ADR-0002 Decision 2. + """ + root = CompetencyCriteriaGroup.objects.create(tag=tag, logic_operator=None) + assert root.parent is None + + child = CompetencyCriteriaGroup.objects.create(tag=tag, parent=root, logic_operator=LogicOperator.AND) + assert child.parent == root + + +def test_group_has_no_unique_constraint_on_parent_and_ordering(tag: Tag) -> None: + """ + No UniqueConstraint on (parent, ordering) exists: two sibling groups may share the same + `ordering` value. A parent's clean() cannot see its own future children at save time (a + child's FK can't point at a not-yet-existing parent row), so there is no single-row state to + check a per-parent uniqueness rule against, and none is declared. See ADR-0002 Decision 2. + """ + unique_constraints = [ + c for c in CompetencyCriteriaGroup._meta.constraints if isinstance(c, models.UniqueConstraint) + ] + assert not any({"parent", "ordering"} <= set(c.fields) for c in unique_constraints) + + parent = CompetencyCriteriaGroup.objects.create(tag=tag) + sibling_a = CompetencyCriteriaGroup.objects.create(tag=tag, parent=parent, ordering=1) + sibling_b = CompetencyCriteriaGroup.objects.create(tag=tag, parent=parent, ordering=1) + assert sibling_a.ordering == sibling_b.ordering == 1 + + +# --------------------------------------------------------------------------------------------- +# Indexes and history + + +# --------------------------------------------------------------------------------------------- + + +def test_the_database_carries_adr_0002_decision_5_indexes_1_and_2() -> None: + """ + The real table carries ADR-0002 Decision 5's index 1, the composite (tag, course), and index + 2 on parent. Index 2 comes from Django's automatic per-ForeignKey index rather than an + explicit models.Index, so this introspects the database rather than the model. + + Compares the ordered column list, not a set: column order is the whole point of a composite + index. An index on (course_id, oel_tagging_tag_id) would satisfy a set comparison just as + well, but only the tag-first ordering also serves tag-only lookups. + """ + with connection.cursor() as cursor: + constraints = connection.introspection.get_constraints( + cursor, CompetencyCriteriaGroup._meta.db_table + ) + + def is_indexed(columns: list[str]) -> bool: + return any(c["columns"] == columns and c["index"] for c in constraints.values()) + + assert is_indexed(["oel_tagging_tag_id", "course_id"]) + assert is_indexed(["parent_id"]) + + +def test_editing_a_group_writes_a_historical_row(tag: Tag) -> None: + """ + HistoricalRecords() is applied to CompetencyCriteriaGroup: the Historical model is registered + under its expected name, and creating then editing a group leaves two rows in it. See + ADR-0003 Decision 1. + + The Historical model is looked up through the app registry rather than the `.history` + attribute because simple_history installs `.history` as a runtime descriptor with no type + stubs, which mypy cannot type. + """ + historical_group = apps.get_model("openedx_learning", "HistoricalCompetencyCriteriaGroup") + group = CompetencyCriteriaGroup.objects.create(tag=tag) + + group.name = "Poetry Mastery" + group.save() + + assert historical_group.objects.filter(id=group.pk).count() == 2 + + +def test_history_not_recorded_for_tag_taxonomy_or_competencytaxonomy(competency_taxonomy: CompetencyTaxonomy) -> None: + """ + django-simple-history is NOT applied to oel_tagging_tag, oel_tagging_taxonomy, or + CompetencyTaxonomy: none of the three has a `.history` attribute, and no Historical* model is + registered for any of them. See ADR-0003 Decisions 1 and 2 for why history tracking stops at + the CBE-specific models and does not reach back into the generic tagging models they build on. + """ + assert not hasattr(Tag, "history") + assert not hasattr(Taxonomy, "history") + assert not hasattr(competency_taxonomy, "history") + + for app_label, model_name in [ + ("oel_tagging", "HistoricalTag"), + ("oel_tagging", "HistoricalTaxonomy"), + ("openedx_learning", "HistoricalCompetencyTaxonomy"), + ]: + with pytest.raises(LookupError): + apps.get_model(app_label, model_name) diff --git a/tests/openedx_learning/applets/cbe/test_criteria_group_deletion.py b/tests/openedx_learning/applets/cbe/test_criteria_group_deletion.py new file mode 100644 index 000000000..2526759e6 --- /dev/null +++ b/tests/openedx_learning/applets/cbe/test_criteria_group_deletion.py @@ -0,0 +1,159 @@ +""" +Delete-behavior tests for CompetencyCriteriaGroup's own foreign keys. + +| Foreign key | Value | Why | +| CompetencyCriteriaGroup.parent | CASCADE | a subtree is meaningless without its parent | +| CompetencyCriteriaGroup.tag | CASCADE | a criteria tree is meaningless without its competency | +| CompetencyCriteriaGroup.course | CASCADE | a course-scoped tree is meaningless without its run | + +``on_delete`` expresses containment rather than protection (ADR-0002 Decision 7): it governs +deletion of the row a foreign key points *at*, never the row holding it. So all three edges above +are how Django's collector walks *down* the tree once something above it is deleted. + +Fixtures live in this directory's conftest.py. +""" +import pytest +from django.apps import apps +from django.db import connection + +from openedx_catalog.models import CourseRun +from openedx_learning.models import CompetencyCriteriaGroup, CompetencyTaxonomy +from openedx_tagging.models import Tag + +pytestmark = pytest.mark.django_db + + +# --------------------------------------------------------------------------------------------- +# Each CASCADE test asserts the referencing row existed beforehand and is gone afterward, not +# merely that no exception was raised. + + +# --------------------------------------------------------------------------------------------- + + +def test_deleting_a_group_also_deletes_its_child_groups(tag: Tag) -> None: + """ + Deleting a CompetencyCriteriaGroup cascades to any child group referencing it via `parent`: + the delete succeeds and the child row is gone too. + """ + root = CompetencyCriteriaGroup.objects.create(tag=tag) + child = CompetencyCriteriaGroup.objects.create(tag=tag, parent=root) + assert CompetencyCriteriaGroup.objects.filter(pk=child.pk).exists() + + root.delete() + + assert not CompetencyCriteriaGroup.objects.filter(pk=root.pk).exists() + assert not CompetencyCriteriaGroup.objects.filter(pk=child.pk).exists() + + +def test_deleting_a_tag_also_deletes_its_competency_criteria_groups(tag: Tag, group: CompetencyCriteriaGroup) -> None: + """ + Deleting a Tag cascades to any CompetencyCriteriaGroup referencing it via `tag`: the delete + succeeds and the group row is gone. Also confirms django-simple-history records the cascaded + removal as its own historical row (history_type='-'), not silently: an author or auditor + reviewing history for a group that vanished this way still finds why it did. + """ + assert CompetencyCriteriaGroup.objects.filter(pk=group.pk).exists() + group_pk = group.pk + + tag.delete() + + assert not CompetencyCriteriaGroup.objects.filter(pk=group_pk).exists() + + historical_group = apps.get_model("openedx_learning", "HistoricalCompetencyCriteriaGroup") + assert historical_group.objects.filter(id=group_pk, history_type="-").exists() + + +def test_deleting_a_course_run_also_deletes_its_course_scoped_criteria_groups( + tag: Tag, course_run: CourseRun +) -> None: + """ + Deleting a CourseRun cascades to any CompetencyCriteriaGroup scoped to it via `course`: the + delete succeeds and the group row is gone too. A course-scoped criteria tree has no meaning + once the course run it evaluates against no longer exists. + """ + group = CompetencyCriteriaGroup.objects.create(tag=tag, course=course_run) + assert CompetencyCriteriaGroup.objects.filter(pk=group.pk).exists() + + course_run.delete() + + assert not CompetencyCriteriaGroup.objects.filter(pk=group.pk).exists() + + +def test_a_cascaded_group_removal_is_recorded_in_history(tag: Tag) -> None: + """ + A group removed by a cascade, rather than by a direct delete, still gets its own historical + row with history_type '-'. An author or auditor reviewing history for a group that vanished + this way still finds why it did. + """ + historical_group = apps.get_model("openedx_learning", "HistoricalCompetencyCriteriaGroup") + group = CompetencyCriteriaGroup.objects.create(tag=tag) + group_pk = group.pk + + tag.delete() + + assert historical_group.objects.filter(id=group_pk, history_type="-").exists() + + +def test_deleting_a_group_at_depth_also_deletes_every_descendant_group(tag: Tag) -> None: + """ + Deleting a CompetencyCriteriaGroup removes not just its direct children but every group + beneath it at any depth: `parent` is a self-referential CASCADE, so a single delete has + Django's collector walk the whole subtree, not just one level. Deleting the root and checking + the grandchild is what actually exercises that recursion; deleting the middle node instead + would only re-prove the one-hop cascade the depth-1 test above already covers. + """ + root = CompetencyCriteriaGroup.objects.create(tag=tag) + child = CompetencyCriteriaGroup.objects.create(tag=tag, parent=root) + grandchild = CompetencyCriteriaGroup.objects.create(tag=tag, parent=child) + + root.delete() + + assert not CompetencyCriteriaGroup.objects.filter(pk=root.pk).exists() + assert not CompetencyCriteriaGroup.objects.filter(pk=child.pk).exists() + assert not CompetencyCriteriaGroup.objects.filter(pk=grandchild.pk).exists() + + +def test_deleting_a_taxonomy_also_deletes_its_tags_criteria_groups(competency_taxonomy: CompetencyTaxonomy) -> None: + """ + Deleting a CompetencyTaxonomy cascades through every Tag it owns (already CASCADE in + openedx_tagging) and, transitively, through this model's own `tag` CASCADE: every + CompetencyCriteriaGroup for a tag under that taxonomy is gone too. + """ + tag = Tag.objects.create(taxonomy=competency_taxonomy, value="Writing Poetry") + group = CompetencyCriteriaGroup.objects.create(tag=tag) + + competency_taxonomy.delete() + + assert not Tag.objects.filter(pk=tag.pk).exists() + assert not CompetencyCriteriaGroup.objects.filter(pk=group.pk).exists() + + +# --------------------------------------------------------------------------------------------- +# MySQL collector semantics, reproduced on SQLite +# MySQL cannot defer foreign-key constraint checks, and Django's CASCADE handler reads that +# flag directly: it nulls a nullable cascading foreign key before the DELETE. On SQLite that +# nulling never happens, so the test below monkeypatches the flag to reproduce it. Without the +# monkeypatch it passes against broken and correct code alike, so do not drop it. + + +# --------------------------------------------------------------------------------------------- + + +def test_course_run_delete_cascades_its_course_scoped_criteria_group_under_mysql_collector_semantics( + monkeypatch: pytest.MonkeyPatch, tag: Tag, course_run: CourseRun +) -> None: + """ + Deleting a CourseRun with a course-scoped CompetencyCriteriaGroup succeeds and cascades the + group away even under MySQL's non-deferred constraint semantics. `course` is a nullable + cascading foreign key, so Django's collector nulls it before the DELETE rather than only + after. CompetencyCriteriaGroup carries no uniqueness constraint a null `course_id` could + collide with, so this path is expected to just succeed; pinned here so a regression that + breaks it does not go unnoticed. + """ + monkeypatch.setattr(type(connection.features), "can_defer_constraint_checks", False, raising=False) + group = CompetencyCriteriaGroup.objects.create(tag=tag, course=course_run) + + course_run.delete() + + assert not CompetencyCriteriaGroup.objects.filter(pk=group.pk).exists()