From af3249916beadd62639af67fdaff12130861c4f7 Mon Sep 17 00:00:00 2001 From: Jesper Hodge Date: Fri, 7 Aug 2026 10:38:29 -0400 Subject: [PATCH] feat: provide openedx_learning djangoapp and CompetencyTaxonomy model --- .importlinter | 9 ++- projects/dev.py | 1 + src/openedx_learning/__init__.py | 5 ++ src/openedx_learning/admin.py | 6 ++ src/openedx_learning/api.py | 6 ++ src/openedx_learning/applets/__init__.py | 3 + src/openedx_learning/applets/cbe/__init__.py | 3 + src/openedx_learning/applets/cbe/admin.py | 17 +++++ src/openedx_learning/applets/cbe/api.py | 38 ++++++++++ src/openedx_learning/applets/cbe/models.py | 52 ++++++++++++++ src/openedx_learning/apps.py | 15 ++++ .../migrations/0001_initial.py | 28 ++++++++ src/openedx_learning/migrations/__init__.py | 0 src/openedx_learning/models.py | 7 ++ src/openedx_learning/models_api.py | 9 +++ src/openedx_learning/py.typed | 0 test_settings.py | 1 + tests/openedx_learning/__init__.py | 0 tests/openedx_learning/applets/__init__.py | 0 .../openedx_learning/applets/cbe/__init__.py | 0 .../openedx_learning/applets/cbe/test_api.py | 50 ++++++++++++++ .../applets/cbe/test_models.py | 69 +++++++++++++++++++ 22 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 src/openedx_learning/__init__.py create mode 100644 src/openedx_learning/admin.py create mode 100644 src/openedx_learning/api.py create mode 100644 src/openedx_learning/applets/__init__.py create mode 100644 src/openedx_learning/applets/cbe/__init__.py create mode 100644 src/openedx_learning/applets/cbe/admin.py create mode 100644 src/openedx_learning/applets/cbe/api.py create mode 100644 src/openedx_learning/applets/cbe/models.py create mode 100644 src/openedx_learning/apps.py create mode 100644 src/openedx_learning/migrations/0001_initial.py create mode 100644 src/openedx_learning/migrations/__init__.py create mode 100644 src/openedx_learning/models.py create mode 100644 src/openedx_learning/models_api.py create mode 100644 src/openedx_learning/py.typed create mode 100644 tests/openedx_learning/__init__.py create mode 100644 tests/openedx_learning/applets/__init__.py create mode 100644 tests/openedx_learning/applets/cbe/__init__.py create mode 100644 tests/openedx_learning/applets/cbe/test_api.py create mode 100644 tests/openedx_learning/applets/cbe/test_models.py diff --git a/.importlinter b/.importlinter index 5b90864cd..17dd176f6 100644 --- a/.importlinter +++ b/.importlinter @@ -5,6 +5,7 @@ [importlinter] root_packages = + openedx_learning openedx_content openedx_tagging openedx_django_lib @@ -17,8 +18,12 @@ root_packages = name = "top-level source folders are layered correctly" type = layers layers = - # Content is currently the highest-level thing in this repo. - # Over time, we may add apps "above" or "below" this. + # Learning-domain features (currently CBE; Learning Pathways to follow). + # May build on content and tagging. Nothing below may import it: in + # particular, openedx_tagging must never know that CBE exists. + openedx_learning + + # Content: authoring-side models and APIs. openedx_content # Tagging is very simple & fundamental. Should probably not depend on any other Django apps. diff --git a/projects/dev.py b/projects/dev.py index 277a78598..28348acab 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -39,6 +39,7 @@ # Our Apps "openedx_catalog", + "openedx_learning", "openedx_tagging", "openedx_content", *openedx_content_backcompat_apps_to_install(), diff --git a/src/openedx_learning/__init__.py b/src/openedx_learning/__init__.py new file mode 100644 index 000000000..341b39932 --- /dev/null +++ b/src/openedx_learning/__init__.py @@ -0,0 +1,5 @@ +""" +Learning-domain features for Open edX Core. + +Currently one applet, cbe, holding the Competency-Based Education models. +""" diff --git a/src/openedx_learning/admin.py b/src/openedx_learning/admin.py new file mode 100644 index 000000000..e065c4048 --- /dev/null +++ b/src/openedx_learning/admin.py @@ -0,0 +1,6 @@ +""" +This module aggregates all applet Django Admin modules. +""" +# pylint: disable=wildcard-import + +from .applets.cbe.admin import * diff --git a/src/openedx_learning/api.py b/src/openedx_learning/api.py new file mode 100644 index 000000000..789a315f3 --- /dev/null +++ b/src/openedx_learning/api.py @@ -0,0 +1,6 @@ +""" +This is the public API for learning-domain features in Open edX Core. +""" +# This wildcard import is okay because the applet api module declares __all__. +# pylint: disable=wildcard-import +from .applets.cbe.api import * diff --git a/src/openedx_learning/applets/__init__.py b/src/openedx_learning/applets/__init__.py new file mode 100644 index 000000000..1879029d5 --- /dev/null +++ b/src/openedx_learning/applets/__init__.py @@ -0,0 +1,3 @@ +""" +The applets that make up the openedx_learning Django app. +""" diff --git a/src/openedx_learning/applets/cbe/__init__.py b/src/openedx_learning/applets/cbe/__init__.py new file mode 100644 index 000000000..850f1f3b1 --- /dev/null +++ b/src/openedx_learning/applets/cbe/__init__.py @@ -0,0 +1,3 @@ +""" +The CBE (Competency-Based Education) applet. +""" diff --git a/src/openedx_learning/applets/cbe/admin.py b/src/openedx_learning/applets/cbe/admin.py new file mode 100644 index 000000000..da7d3ca2b --- /dev/null +++ b/src/openedx_learning/applets/cbe/admin.py @@ -0,0 +1,17 @@ +""" +Django Admin pages for CBE models. +""" +from django.contrib import admin + +from .models import CompetencyTaxonomy + + +class CompetencyTaxonomyAdmin(admin.ModelAdmin): + """ + The CompetencyTaxonomy model admin. + """ + list_display = ["name", "export_id", "enabled", "taxonomy_overrides_org"] + list_filter = ["enabled", "taxonomy_overrides_org"] + + +admin.site.register(CompetencyTaxonomy, CompetencyTaxonomyAdmin) diff --git a/src/openedx_learning/applets/cbe/api.py b/src/openedx_learning/applets/cbe/api.py new file mode 100644 index 000000000..2fede2c3d --- /dev/null +++ b/src/openedx_learning/applets/cbe/api.py @@ -0,0 +1,38 @@ +""" +Public API for Competency-Based Education (CBE). +""" +from __future__ import annotations + +from django.db.models import QuerySet + +from openedx_tagging.models import Taxonomy + +__all__ = [ + "is_competency_taxonomy", + "select_competency_taxonomies", +] + +# The accessor Django generates for the multi-table-inheritance link from Taxonomy to +# CompetencyTaxonomy. Deliberately private: callers use the functions below rather than +# spelling this out, so a model rename is a one-line change here and nowhere else. +_COMPETENCY_TAXONOMY_RELATION = "competencytaxonomy" + + +def is_competency_taxonomy(taxonomy: Taxonomy) -> bool: + """ + Return True if ``taxonomy`` is competency-enabled, i.e. has a CompetencyTaxonomy row. + + Costs one query per call unless ``taxonomy`` came from a queryset passed through + :func:`select_competency_taxonomies`. + """ + return hasattr(taxonomy, _COMPETENCY_TAXONOMY_RELATION) + + +def select_competency_taxonomies(taxonomies: QuerySet[Taxonomy]) -> QuerySet[Taxonomy]: + """ + Return ``taxonomies`` with each CompetencyTaxonomy row joined in. + + Pair this with :func:`is_competency_taxonomy` when checking more than one taxonomy, + so the check costs no additional query per row. + """ + return taxonomies.select_related(_COMPETENCY_TAXONOMY_RELATION) diff --git a/src/openedx_learning/applets/cbe/models.py b/src/openedx_learning/applets/cbe/models.py new file mode 100644 index 000000000..f89e2af4c --- /dev/null +++ b/src/openedx_learning/applets/cbe/models.py @@ -0,0 +1,52 @@ +""" +Models for Competency-Based Education (CBE). +""" +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from openedx_tagging.models import Taxonomy + +__all__ = [ + "CompetencyTaxonomy", +] + + +class CompetencyTaxonomy(Taxonomy): + """ + Marks a Taxonomy as competency-enabled, so CBE features apply to its tags. + + A taxonomy listed in this table: + + - can be displayed in the competency criteria association view. + - can be displayed in the competency progress tracking views. + - can also be displayed in the existing generic taxonomy views. + - constrains its associated content objects to those supported for progress + tracking, and to ones that could logically be used to demonstrate mastery of + the competency (for example, associating both a course and one assignment + within that same course would be ambiguous). + + A taxonomy *not* listed here: + + - is only displayed in the existing generic taxonomy views. + - is not displayed in competency criteria association views. + - is not displayed in competency progress tracking views. + - has no competency-specific constraints on its associated content objects. + + Creating a competency taxonomy creates both the parent ``Taxonomy`` row and this + row in one transaction; deleting either row removes both. + + .. no_pii: + """ + + taxonomy_overrides_org = models.BooleanField( + default=False, + help_text=_( + "When both an organization-scoped and a taxonomy-scoped rule profile " + "could apply to a criterion, this decides which one is assigned: false " + "assigns the organization's, true assigns this taxonomy's." + ), + ) + + class Meta: + verbose_name = "Competency Taxonomy" + verbose_name_plural = "Competency Taxonomies" diff --git a/src/openedx_learning/apps.py b/src/openedx_learning/apps.py new file mode 100644 index 000000000..070504eba --- /dev/null +++ b/src/openedx_learning/apps.py @@ -0,0 +1,15 @@ +""" +App Config for our umbrella openedx_learning app. +""" +from django.apps import AppConfig + + +class LearningConfig(AppConfig): + """ + Initialization for all applets must happen in here. + """ + + name = "openedx_learning" + verbose_name = "Open edX Core > Learning" + default_auto_field = "django.db.models.BigAutoField" + label = "openedx_learning" diff --git a/src/openedx_learning/migrations/0001_initial.py b/src/openedx_learning/migrations/0001_initial.py new file mode 100644 index 000000000..29e51fd1f --- /dev/null +++ b/src/openedx_learning/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.16 on 2026-08-06 19:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('oel_tagging', '0020_tag_depth_and_lineage'), + ] + + operations = [ + migrations.CreateModel( + name='CompetencyTaxonomy', + fields=[ + ('taxonomy_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_tagging.taxonomy')), + ('taxonomy_overrides_org', models.BooleanField(default=False, help_text="When both an organization-scoped and a taxonomy-scoped rule profile could apply to a criterion, this decides which one is assigned: false assigns the organization's, true assigns this taxonomy's.")), + ], + options={ + 'verbose_name': 'Competency Taxonomy', + 'verbose_name_plural': 'Competency Taxonomies', + }, + bases=('oel_tagging.taxonomy',), + ), + ] diff --git a/src/openedx_learning/migrations/__init__.py b/src/openedx_learning/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/openedx_learning/models.py b/src/openedx_learning/models.py new file mode 100644 index 000000000..c008b9905 --- /dev/null +++ b/src/openedx_learning/models.py @@ -0,0 +1,7 @@ +""" +This module aggregates all applet model modules. +""" + +# pylint: disable=wildcard-import + +from .applets.cbe.models import * diff --git a/src/openedx_learning/models_api.py b/src/openedx_learning/models_api.py new file mode 100644 index 000000000..4f02b4f98 --- /dev/null +++ b/src/openedx_learning/models_api.py @@ -0,0 +1,9 @@ +""" +Models that we want callers to extend or make foreign keys to. + +This is also the stable import point for the model class itself, for callers that +need to create competency taxonomies directly. +""" + +# pylint: disable=unused-import +from .models import CompetencyTaxonomy diff --git a/src/openedx_learning/py.typed b/src/openedx_learning/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/test_settings.py b/test_settings.py index e1e250134..be0a84904 100644 --- a/test_settings.py +++ b/test_settings.py @@ -58,6 +58,7 @@ def root(*args): "openedx_tagging", "openedx_content", "openedx_catalog", + "openedx_learning", *openedx_content_backcompat_apps_to_install(), # Apps with models that are only used for testing "tests.test_django_app", diff --git a/tests/openedx_learning/__init__.py b/tests/openedx_learning/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/openedx_learning/applets/__init__.py b/tests/openedx_learning/applets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/openedx_learning/applets/cbe/__init__.py b/tests/openedx_learning/applets/cbe/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/openedx_learning/applets/cbe/test_api.py b/tests/openedx_learning/applets/cbe/test_api.py new file mode 100644 index 000000000..ae5ec93bc --- /dev/null +++ b/tests/openedx_learning/applets/cbe/test_api.py @@ -0,0 +1,50 @@ +""" +Tests for the CBE public API surface (openedx_learning.api). +""" +import pytest + +from openedx_learning.api import is_competency_taxonomy, select_competency_taxonomies +from openedx_learning.models import CompetencyTaxonomy +from openedx_tagging.models import Taxonomy + +pytestmark = pytest.mark.django_db + + +def test_is_competency_taxonomy() -> None: + """ + is_competency_taxonomy() is True for a competency taxonomy, False for a plain one. + """ + competency = CompetencyTaxonomy.objects.create(name="Nursing", export_id="nursing-v1") + plain = Taxonomy.objects.create(name="Plain Tags", export_id="plain-v1") + + assert is_competency_taxonomy(Taxonomy.objects.get(pk=competency.pk)) is True + assert is_competency_taxonomy(plain) is False + + +def test_is_competency_taxonomy_on_child_instance_directly() -> None: + """ + is_competency_taxonomy() also returns True when handed a CompetencyTaxonomy + instance directly, not just a parent Taxonomy fetched from the DB. + """ + competency = CompetencyTaxonomy.objects.create(name="Nursing", export_id="nursing-v1") + assert is_competency_taxonomy(competency) is True + + +def test_select_competency_taxonomies_avoids_n_plus_1(django_assert_num_queries) -> None: + """ + select_competency_taxonomies() joins the CompetencyTaxonomy row in, so checking + is_competency_taxonomy() on every row in the queryset costs one query, not N+1. + """ + competency1 = CompetencyTaxonomy.objects.create(name="Nursing", export_id="nursing-v1") + competency2 = CompetencyTaxonomy.objects.create(name="Welding", export_id="welding-v1") + plain = Taxonomy.objects.create(name="Plain Tags", export_id="plain-v1") + # Scoped to just these three: unfiltered Taxonomy.objects.all() also picks up the + # system-seeded "Language" taxonomy from oel_tagging's data migration, which would + # make the True/False counts below depend on incidental fixture data. + taxonomies = Taxonomy.objects.filter(pk__in=[competency1.pk, competency2.pk, plain.pk]) + + with django_assert_num_queries(1): + results = [is_competency_taxonomy(t) for t in select_competency_taxonomies(taxonomies)] + + assert results.count(True) == 2 + assert results.count(False) == 1 diff --git a/tests/openedx_learning/applets/cbe/test_models.py b/tests/openedx_learning/applets/cbe/test_models.py new file mode 100644 index 000000000..95eb195f8 --- /dev/null +++ b/tests/openedx_learning/applets/cbe/test_models.py @@ -0,0 +1,69 @@ +""" +Tests for the CompetencyTaxonomy model. +""" +import pytest + +from openedx_learning.models import CompetencyTaxonomy +from openedx_tagging.models import Taxonomy + +pytestmark = pytest.mark.django_db + +# The default MTI reverse accessor. django-stubs cannot see dynamically added +# accessors, so these tests reach it by name; that name is the ADR-0013 contract. +RELATION = "competencytaxonomy" + + +@pytest.fixture(name="competency_taxonomy") +def _competency_taxonomy() -> CompetencyTaxonomy: + """Create a CompetencyTaxonomy for use in these tests.""" + return CompetencyTaxonomy.objects.create(name="Nursing", export_id="nursing-v1") + + +def test_create_writes_both_rows(competency_taxonomy: CompetencyTaxonomy) -> None: + """ + Creating a CompetencyTaxonomy writes both the parent Taxonomy row and the child row. + """ + assert Taxonomy.objects.filter(pk=competency_taxonomy.pk).exists() + assert CompetencyTaxonomy.objects.filter(pk=competency_taxonomy.pk).exists() + + +def test_taxonomy_overrides_org_defaults_false(competency_taxonomy: CompetencyTaxonomy) -> None: + """ + taxonomy_overrides_org defaults to False. + """ + assert competency_taxonomy.taxonomy_overrides_org is False + + +def test_mti_round_trip(competency_taxonomy: CompetencyTaxonomy) -> None: + """ + The MTI relationship works in both directions: the child reads the parent's + fields directly, and the parent reaches the child via the default accessor. + """ + assert competency_taxonomy.name == "Nursing" + parent = Taxonomy.objects.get(pk=competency_taxonomy.pk) + assert getattr(parent, RELATION) == competency_taxonomy + + +def test_plain_taxonomy_has_no_competencytaxonomy() -> None: + """ + A plain Taxonomy (no CompetencyTaxonomy row) raises RelatedObjectDoesNotExist. + """ + plain = Taxonomy.objects.create(name="Plain Tags", export_id="plain-v1") + # Django builds the accessor's RelatedObjectDoesNotExist as a subclass of the child + # model's DoesNotExist, so catching that names no dynamically added attribute. + with pytest.raises(CompetencyTaxonomy.DoesNotExist): + getattr(plain, RELATION) + + +def test_delete_cascades_both_directions() -> None: + """ + Deleting the parent Taxonomy removes the CompetencyTaxonomy row, and deleting + the child removes the parent row too. + """ + ct1 = CompetencyTaxonomy.objects.create(name="Nursing", export_id="nursing-v1") + Taxonomy.objects.get(pk=ct1.pk).delete() + assert not CompetencyTaxonomy.objects.filter(pk=ct1.pk).exists() + + ct2 = CompetencyTaxonomy.objects.create(name="Welding", export_id="welding-v1") + ct2.delete() + assert not Taxonomy.objects.filter(pk=ct2.pk).exists()