Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/openedx_learning/applets/cbe/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ taxonomy; put a queryset through ``select_competency_taxonomies()`` first when c
list, so the whole list costs one query instead of one per row. Keeping the check here is
what lets ``openedx_tagging`` stay a generic tagging library that never learns CBE exists.

The criteria, rule profile, and learner status tables this applet still needs are designed
in ``docs/openedx_learning/decisions/``. ``openedx_tagging`` ADR 0013 covers how
The criteria, rule profile, and learner status tables are designed in
``docs/openedx_learning/decisions/``. ``openedx_tagging`` ADR 0013 covers how
``openedx-platform`` calls ``is_competency_taxonomy()`` to report a taxonomy's type to
Studio.
10 changes: 9 additions & 1 deletion src/openedx_learning/applets/cbe/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
from ..rule_payloads import RuleType
from .competency_taxonomy import CompetencyTaxonomy
from .criteria import CompetencyCriteriaGroup, CompetencyCriterion, CompetencyRuleProfile, LogicOperator
from .learner_status import CompetencyMasteryStatus, MasteryStatus, StudentCompetencyStatus
from .learner_status import (
CompetencyMasteryStatus,
MasteryStatus,
StudentCompetencyCriteriaGroupStatus,
StudentCompetencyCriterionStatus,
StudentCompetencyStatus,
)

__all__ = [
"CompetencyCriteriaGroup",
Expand All @@ -16,5 +22,7 @@
"LogicOperator",
"MasteryStatus",
"RuleType",
"StudentCompetencyCriteriaGroupStatus",
"StudentCompetencyCriterionStatus",
"StudentCompetencyStatus",
]
93 changes: 92 additions & 1 deletion src/openedx_learning/applets/cbe/models/learner_status.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
"""
Models tracking a learner's mastery status for a competency.
Models tracking a learner's mastery status at each level of a criteria tree.

:class:`StudentCompetencyCriterionStatus` covers one leaf :class:`~openedx_learning.models.CompetencyCriterion`,
:class:`StudentCompetencyCriteriaGroupStatus` covers one
:class:`~openedx_learning.models.CompetencyCriteriaGroup`, and :class:`StudentCompetencyStatus` covers one
:class:`~openedx_tagging.models.Tag`. Each holds one row per learner per node, updated in place (ADR-0003
Decision 5). Which writes may raise or lower a status is decided in the API layer, not here (ADR-0004
Decisions 4 and 6). ``created`` and ``modified`` are caller-supplied because ``auto_now`` does not fire on
``QuerySet.update()``, which is how conditional raises are written. ``user`` is ``CASCADE`` so that this
library never vetoes ``User.delete()`` platform-wide. The node foreign keys are ``PROTECT`` because every
foreign key above them in the criteria tree is ``CASCADE``: without ``PROTECT``, deleting a tag, taxonomy,
group or ``ObjectTag`` would silently delete the learner status beneath it (ADR-0002 Decision 7).
"""
from django.conf import settings
from django.db import models

from openedx_django_lib.fields import manual_date_time_field
from openedx_tagging.models import Tag

from .criteria import CompetencyCriteriaGroup, CompetencyCriterion

__all__ = [
"MasteryStatus",
"CompetencyMasteryStatus",
"StudentCompetencyStatus",
"StudentCompetencyCriterionStatus",
"StudentCompetencyCriteriaGroupStatus",
]


Expand Down Expand Up @@ -97,3 +112,79 @@ class Meta:
name="oex_learning_studentcompetencystatus_status_allowed",
),
]


class StudentCompetencyCriterionStatus(models.Model):
"""
A learner's current mastery status for one leaf ``CompetencyCriterion``.

One row per learner per criterion, updated in place (ADR-0003 Decision 5).

.. no_pii:
"""

user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="+",
)
criterion = models.ForeignKey(
CompetencyCriterion,
db_column="competency_criteria_id",
on_delete=models.PROTECT,
related_name="+",
)
status = models.ForeignKey(
CompetencyMasteryStatus,
on_delete=models.PROTECT,
related_name="+",
)
created = manual_date_time_field()
modified = manual_date_time_field()

class Meta:
constraints = [
# ADR-0002 Decision 5 index 6.
models.UniqueConstraint(
fields=("user", "criterion"),
name="oex_learning_studentcriterionstatus_user_criterion_uniq",
),
]


class StudentCompetencyCriteriaGroupStatus(models.Model):
"""
A learner's current mastery status for one ``CompetencyCriteriaGroup``.

One row per learner per group, updated in place (ADR-0003 Decision 5).

.. no_pii:
"""

user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="+",
)
group = models.ForeignKey(
CompetencyCriteriaGroup,
db_column="competency_criteria_group_id",
on_delete=models.PROTECT,
related_name="+",
)
status = models.ForeignKey(
CompetencyMasteryStatus,
on_delete=models.PROTECT,
related_name="+",
)
created = manual_date_time_field()
modified = manual_date_time_field()

class Meta:
constraints = [
# ADR-0002 Decision 5 index 7.
models.UniqueConstraint(
fields=("user", "group"),
name="oex_learning_studentcriteriagroupstatus_user_group_uniq",
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 5.2.16 on 2026-09-28 16:09

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models

import openedx_django_lib.validators


class Migration(migrations.Migration):

dependencies = [
('openedx_learning', '0010_studentcompetencystatus'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='StudentCompetencyCriterionStatus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])),
('modified', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])),
('criterion', models.ForeignKey(db_column='competency_criteria_id', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='openedx_learning.competencycriterion')),
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='openedx_learning.competencymasterystatus')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'constraints': [models.UniqueConstraint(fields=('user', 'criterion'), name='oex_learning_studentcriterionstatus_user_criterion_uniq')],
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 5.2.16 on 2026-09-28 16:26

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models

import openedx_django_lib.validators


class Migration(migrations.Migration):

dependencies = [
('openedx_learning', '0011_studentcompetencycriterionstatus'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='StudentCompetencyCriteriaGroupStatus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])),
('modified', models.DateTimeField(validators=[openedx_django_lib.validators.validate_utc_datetime])),
('group', models.ForeignKey(db_column='competency_criteria_group_id', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='openedx_learning.competencycriteriagroup')),
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='openedx_learning.competencymasterystatus')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'constraints': [models.UniqueConstraint(fields=('user', 'group'), name='oex_learning_studentcriteriagroupstatus_user_group_uniq')],
},
),
]
15 changes: 15 additions & 0 deletions tests/openedx_learning/applets/cbe/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Shared fixtures for the CBE criteria test modules."""
from datetime import datetime, timezone

import pytest
from django.contrib.auth import get_user_model
from organizations.api import ensure_organization
from organizations.models import Organization

Expand Down Expand Up @@ -65,3 +68,15 @@ def _default_rule_profile() -> CompetencyRuleProfile:
course__isnull=True,
competency_taxonomy__isnull=True,
)


@pytest.fixture(name="user")
def _user():
"""Create a single learner for use in these tests."""
return get_user_model().objects.create(username="learner")


@pytest.fixture(name="now")
def _now() -> datetime:
"""A single UTC timestamp shared by writes in a test."""
return datetime.now(timezone.utc)
Loading
Loading