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
8 changes: 7 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,12 @@
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,
StudentCompetencyCriteriaStatus,
StudentCompetencyStatus,
)

__all__ = [
"CompetencyCriteriaGroup",
Expand All @@ -16,5 +21,6 @@
"LogicOperator",
"MasteryStatus",
"RuleType",
"StudentCompetencyCriteriaStatus",
"StudentCompetencyStatus",
]
53 changes: 52 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,29 @@
"""
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:`StudentCompetencyCriteriaStatus` covers one leaf :class:`~openedx_learning.models.CompetencyCriterion`
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 CompetencyCriterion

__all__ = [
"MasteryStatus",
"CompetencyMasteryStatus",
"StudentCompetencyStatus",
"StudentCompetencyCriteriaStatus",
]


Expand Down Expand Up @@ -88,3 +101,41 @@ class Meta:
name="oex_learning_studentcompetencystatus_status_allowed",
),
]


class StudentCompetencyCriteriaStatus(models.Model):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
class StudentCompetencyCriteriaStatus(models.Model):
class StudentCompetencyCriterionStatus(models.Model):

Please use the singular here, since that's the Django model convention.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Done, it's StudentCompetencyCriterionStatus now, and I renamed its migration to match (openedx#854).

"""
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_studentcriteriastatus_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: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='StudentCompetencyCriteriaStatus',
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_studentcriteriastatus_user_criterion_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