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: 4 additions & 0 deletions src/openedx_learning/applets/cbe/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
from ..rule_payloads import RuleType
from .competency_taxonomy import CompetencyTaxonomy
from .criteria import CompetencyCriteriaGroup, CompetencyCriterion, CompetencyRuleProfile, LogicOperator
from .learner_status import CompetencyMasteryStatus, MasteryStatus, StudentCompetencyStatus

__all__ = [
"CompetencyCriteriaGroup",
"CompetencyCriterion",
"CompetencyMasteryStatus",
"CompetencyRuleProfile",
"CompetencyTaxonomy",
"LogicOperator",
"MasteryStatus",
"RuleType",
"StudentCompetencyStatus",
]
99 changes: 99 additions & 0 deletions src/openedx_learning/applets/cbe/models/learner_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""
Models tracking a learner's mastery status for a competency.
"""
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

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


class MasteryStatus(models.IntegerChoices):
"""
Ranks of competency mastery, lowest to highest.

The values are the ``CompetencyMasteryStatus`` primary keys, and the database compares them
directly to decide whether a write raises a status, so their order is a contract. They are
spaced by 10 so that a new rank can go between two existing ones without renumbering
stored rows.
"""

ATTEMPTED_NOT_DEMONSTRATED = 10, "AttemptedNotDemonstrated"
PARTIALLY_ATTEMPTED = 20, "PartiallyAttempted"
DEMONSTRATED = 30, "Demonstrated"


class CompetencyMasteryStatus(models.Model):
"""
Lookup table of the mastery statuses a competency can be assigned.

System-owned lookup data, seeded by the ``seed_competency_mastery_statuses`` data
migration and treated as immutable configuration, not user-authored rows (ADR-0002
Decision 6.1). See :class:`MasteryStatus` for the pinned ids and names of its rows.

.. no_pii:
"""

id = models.SmallAutoField(primary_key=True)

# ADR-0002 Decision 5 index 10.
status = models.CharField(max_length=64, unique=True)

def __str__(self) -> str:
"""User-facing string representation of a CompetencyMasteryStatus."""
return self.status


class StudentCompetencyStatus(models.Model):
"""
A learner's current mastery status for one competency (``Tag``).

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

.. no_pii:
"""

user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="+",
)
tag = models.ForeignKey(
Tag,
db_column="oel_tagging_tag_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 8. This is what makes "one row per learner and
# competency" true, which is the precondition for updating a status in place
# with a conditional UPDATE.
models.UniqueConstraint(
fields=("user", "tag"),
name="oex_learning_studentcompetencystatus_user_tag_uniq",
),
# A learner can still demonstrate a competency later, in another course, and
# nothing here can tell when that stops being possible, so a top-level row never
# records AttemptedNotDemonstrated. This is an allow list rather than a negation
# of the excluded value, so a future status is rejected here by default rather
# than silently permitted.
models.CheckConstraint(
condition=models.Q(status__in=(MasteryStatus.PARTIALLY_ATTEMPTED, MasteryStatus.DEMONSTRATED)),
name="oex_learning_studentcompetencystatus_status_allowed",
),
]
18 changes: 18 additions & 0 deletions src/openedx_learning/migrations/0008_competency_mastery_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('openedx_learning', '0007_alter_criterion_override_help_text'),
]

operations = [
migrations.CreateModel(
name='CompetencyMasteryStatus',
fields=[
('id', models.SmallAutoField(primary_key=True, serialize=False)),
('status', models.CharField(max_length=64, unique=True)),
],
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from django.db import migrations

# These ids and strings are literals, not references to `MasteryStatus`
# (`src/openedx_learning/applets/cbe/models/learner_status.py`), and must stay that way: once
# applied, a migration has to keep meaning what it meant at the time it ran, so
# it cannot depend on a constant that a later edit to that enum could change.
# See `MasteryStatus` for the names these ids correspond to.


def forward(apps, schema_editor):
"""
Seed the three CompetencyMasteryStatus rows, in rank order.
"""
CompetencyMasteryStatus = apps.get_model("openedx_learning", "CompetencyMasteryStatus")
CompetencyMasteryStatus.objects.get_or_create(id=10, defaults={"status": "AttemptedNotDemonstrated"})
CompetencyMasteryStatus.objects.get_or_create(id=20, defaults={"status": "PartiallyAttempted"})
CompetencyMasteryStatus.objects.get_or_create(id=30, defaults={"status": "Demonstrated"})


def revert(apps, schema_editor):
"""
Delete every CompetencyMasteryStatus row, leaving the table as 0008 created it.
"""
CompetencyMasteryStatus = apps.get_model("openedx_learning", "CompetencyMasteryStatus")
CompetencyMasteryStatus.objects.all().delete()


class Migration(migrations.Migration):

dependencies = [
("openedx_learning", "0008_competency_mastery_status"),
]

operations = [
migrations.RunPython(forward, revert),
]
31 changes: 31 additions & 0 deletions src/openedx_learning/migrations/0010_studentcompetencystatus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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 = [
('oel_tagging', '0021_remove_system_defined_add_read_only'),
('openedx_learning', '0009_seed_competency_mastery_statuses'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='StudentCompetencyStatus',
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])),
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='openedx_learning.competencymasterystatus')),
('tag', models.ForeignKey(db_column='oel_tagging_tag_id', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='oel_tagging.tag')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'constraints': [models.UniqueConstraint(fields=('user', 'tag'), name='oex_learning_studentcompetencystatus_user_tag_uniq'), models.CheckConstraint(condition=models.Q(('status__in', (20, 30))), name='oex_learning_studentcompetencystatus_status_allowed')],
},
),
]
Loading
Loading