Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

[importlinter]
root_packages =
openedx_learning
openedx_content
openedx_tagging
openedx_django_lib
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Decision

Report a taxonomy's type entirely within **openedx-platform**, using the existing relation
between ``Taxonomy`` and ``CompetencyTaxonomy`` established in ADR 0002, without adding any
field, method, or enum value to ``openedx_tagging`` or the CBE app:
field, method, or enum value to ``openedx_tagging``:

.. image:: images/CompetencyTypeDetection.png
:alt: Studio calls openedx-platform's serializer, which delegates to oel_tagging's pure
Expand All @@ -41,22 +41,29 @@ field, method, or enum value to ``openedx_tagging`` or the CBE app:
- openedx-platform's REST layer adds a read-only ``taxonomy_type`` value to its taxonomy
serializer, computed by checking whether a related ``CompetencyTaxonomy`` row exists for
that ``Taxonomy``: ``"competency"`` if so, ``"tags"`` otherwise.
- It performs that check through the CBE app's public API,
``openedx_learning.api.is_competency_taxonomy()``, rather than naming the relation itself.
The relation name is a Django-generated default derived from the model's class name, so
spelling it in openedx-platform would let a rename upstream break Studio with nothing
failing in either repository's tests.
- That same layer's queryset fetches the related ``CompetencyTaxonomy`` row alongside the
``Taxonomy`` list, so the check costs no extra query per row.
- ``openedx_tagging``'s ``Taxonomy`` model, its base ``TaxonomySerializer``, and the CBE app
stay fully unaware of each other for this purpose: no new field, no new enum value, no
import.
``Taxonomy`` list, using the companion ``select_competency_taxonomies()``, so the check
costs no extra query per row.
- ``openedx_tagging``'s ``Taxonomy`` model and its base ``TaxonomySerializer`` gain nothing
for this purpose: no new field, no new enum value, no import. That constraint is on
``openedx_tagging`` alone. The CBE app owns this relation, so exposing it through the CBE
app's own public API is expected rather than avoided.
- No creation-time wiring is needed to keep this accurate: ADR 0002 Decision 1 already
creates the ``CompetencyTaxonomy`` row in the same transaction as its parent ``Taxonomy``
row, so the existence check can never drift out of sync the way a separately-stored
field could.

**Known trade-off.** A future third taxonomy type needs another hardcoded branch in
openedx-platform's shared serializer, the same cost a field-based approach would have
avoided with a one-line enum addition. Accepted because keeping ``openedx_tagging`` and the
CBE app free of any competency-specific reference, even an inert stored value, was judged
more valuable than that extensibility, particularly given the project's move away from
system-defined taxonomies, which makes a third taxonomy flavor unlikely soon.
avoided with a one-line enum addition. Accepted because keeping ``openedx_tagging`` free of
any competency-specific reference, even an inert stored value, was judged more valuable
than that extensibility, particularly given the project's move away from system-defined
taxonomies, which makes a third taxonomy flavor unlikely soon.

Rejected Alternatives
----------------------
Expand All @@ -69,9 +76,8 @@ A ``TaxonomyType(models.TextChoices)`` field (``TAGS``/``COMPETENCY``) added dir
transaction as ADR 0002 Decision 1's existing lifecycle rule. Although this requires no
per-request check and was more extensible for a hypothetical third taxonomy flavor, it
still named a CBE-specific concept, a ``COMPETENCY`` enum value, directly in
``openedx_tagging``'s own schema and public API. Keeping ``openedx_tagging`` and the CBE
app fully free of any competency-specific reference, even an inert one, is worth the lost
extensibility.
``openedx_tagging``'s own schema and public API. Keeping ``openedx_tagging`` fully free of
any competency-specific reference, even an inert one, is worth the lost extensibility.

Check for a related ``CompetencyTaxonomy`` row directly inside ``openedx_tagging``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
1 change: 1 addition & 0 deletions projects/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

# Our Apps
"openedx_catalog",
"openedx_learning",
"openedx_tagging",
"openedx_content",
*openedx_content_backcompat_apps_to_install(),
Expand Down
5 changes: 5 additions & 0 deletions src/openedx_learning/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Learning-domain features for Open edX Core.

Currently one applet, cbe, holding the Competency-Based Education models.
"""
6 changes: 6 additions & 0 deletions src/openedx_learning/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
This module aggregates all applet Django Admin modules.
"""
# pylint: disable=wildcard-import

from .applets.cbe.admin import *
6 changes: 6 additions & 0 deletions src/openedx_learning/api.py
Original file line number Diff line number Diff line change
@@ -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 *
3 changes: 3 additions & 0 deletions src/openedx_learning/applets/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
The applets that make up the openedx_learning Django app.
"""
3 changes: 3 additions & 0 deletions src/openedx_learning/applets/cbe/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
The CBE (Competency-Based Education) applet.
"""
17 changes: 17 additions & 0 deletions src/openedx_learning/applets/cbe/admin.py
Original file line number Diff line number Diff line change
@@ -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)
38 changes: 38 additions & 0 deletions src/openedx_learning/applets/cbe/api.py
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +15 to +18

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i think that this is unnecessarily indirect, and as an abstraction it's leaky. you cannot stop callers from accessing taxonomy.competencytaxonomy directly, so renaming the CompetencyTaxonomy model would be a breaking change--we'd best treat it that way rather than pretending that all references to .competencytaxonomy will go through the two functions below.

I would just use the "competencytaxonomy" string literal in the function below, as is idiomatic in django code. mypy is sometimes actually smart enough to do type checking on expressions like taxonomies.select_related("competencytaxonomy"), but when it's been abstracted out to taxonomies.select_related(_COMPETENCY_TAXONOMY_RELATION), it will never be able to do any static analysis.



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)
52 changes: 52 additions & 0 deletions src/openedx_learning/applets/cbe/models.py
Original file line number Diff line number Diff line change
@@ -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."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

could you add a note stating that this isn't yet used anywhere? bonus points if you add a comment linking to a task or epic issue that would implement it.

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.

We do not have a task or epic that would implement this since it's out of scope. Epic 6 is what most closely relates to it though, but we don't currently have Epic level Github Issues, so that's a handful of issues. The one most closely related is probably #631 though.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If it's out of scope for Willow, then can we omit the field for now @mgwozdz-unicon ?

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 think it would be fine to omit the field for now.

),
)

class Meta:
verbose_name = "Competency Taxonomy"
verbose_name_plural = "Competency Taxonomies"
15 changes: 15 additions & 0 deletions src/openedx_learning/apps.py
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 28 additions & 0 deletions src/openedx_learning/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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',),
),
]
Empty file.
7 changes: 7 additions & 0 deletions src/openedx_learning/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""
This module aggregates all applet model modules.
"""

# pylint: disable=wildcard-import

from .applets.cbe.models import *
9 changes: 9 additions & 0 deletions src/openedx_learning/models_api.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added src/openedx_learning/py.typed
Empty file.
1 change: 1 addition & 0 deletions test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Empty file.
Empty file.
Empty file.
50 changes: 50 additions & 0 deletions tests/openedx_learning/applets/cbe/test_api.py
Original file line number Diff line number Diff line change
@@ -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
Loading