diff --git a/src/openedx_core/__init__.py b/src/openedx_core/__init__.py index 5881dfd19..b0a12ce84 100644 --- a/src/openedx_core/__init__.py +++ b/src/openedx_core/__init__.py @@ -6,4 +6,4 @@ """ # The version for the entire repository -__version__ = "1.4.0" +__version__ = "1.5.0" diff --git a/src/openedx_learning/applets/cbe/models/__init__.py b/src/openedx_learning/applets/cbe/models/__init__.py new file mode 100644 index 000000000..c9fa01adc --- /dev/null +++ b/src/openedx_learning/applets/cbe/models/__init__.py @@ -0,0 +1,13 @@ +""" +Models for Competency-Based Education (CBE). +""" + +from .competency_taxonomy import CompetencyTaxonomy +from .learner_status import CompetencyMasteryStatus, MasteryStatus, StudentCompetencyStatus + +__all__ = [ + "CompetencyTaxonomy", + "MasteryStatus", + "CompetencyMasteryStatus", + "StudentCompetencyStatus", +] diff --git a/src/openedx_learning/applets/cbe/models.py b/src/openedx_learning/applets/cbe/models/competency_taxonomy.py similarity index 96% rename from src/openedx_learning/applets/cbe/models.py rename to src/openedx_learning/applets/cbe/models/competency_taxonomy.py index 7cbd8cb3a..05ae90147 100644 --- a/src/openedx_learning/applets/cbe/models.py +++ b/src/openedx_learning/applets/cbe/models/competency_taxonomy.py @@ -1,5 +1,5 @@ """ -Models for Competency-Based Education (CBE). +The CompetencyTaxonomy model. """ from openedx_tagging.models import Taxonomy diff --git a/src/openedx_learning/applets/cbe/models/learner_status.py b/src/openedx_learning/applets/cbe/models/learner_status.py new file mode 100644 index 000000000..4e30302fe --- /dev/null +++ b/src/openedx_learning/applets/cbe/models/learner_status.py @@ -0,0 +1,145 @@ +""" +Models tracking a learner's overall mastery status for a competency. + +:class:`StudentCompetencyStatus` tracks a learner's current mastery rank at the top +(:class:`~openedx_tagging.models.Tag`) level of a criteria tree; :class:`CompetencyMasteryStatus` +is the lookup table of ranks it points at. There is one :class:`StudentCompetencyStatus` row per +learner per tag, updated in place: finding a learner's current status is a lookup of that single +row, not a query for the most recent of several (ADR-0003 Decision 5). The model accepts any +status value the allow-list check constraint below permits; the rules that decide *which* writes +are allowed, that an automatic write may raise a status but never lower it (ADR-0004 Decision 4), +and that a staff correction may lower one (ADR-0004 Decision 6), are enforced in the API layer, +not here: by the time a write reaches this model there is no caller context left to tell those +cases apart. + +``created`` and ``modified`` are caller-supplied UTC datetimes, not automatic. A caller +performing a conditional raise must pass ``modified`` in the same ``update()`` call; there is no +``auto_now`` to do it for them, deliberately, because ``auto_now`` does not fire on +``QuerySet.update()`` and would silently leave the column stale on exactly that path. + +``user`` is ``on_delete=models.CASCADE``, not ``PROTECT``: ``PROTECT`` would let this library +veto ``User.delete()`` platform-wide, from openedx-platform code that has no reason to know CBE +rows exist. A learner's status is a derived fact about that learner, so it goes when they do. +``SET_NULL`` is not an option, because a null ``user_id`` would break the ``(user_id, tag_id)`` +uniqueness this model's in-place updates rest on. + +``tag`` is ``on_delete=models.PROTECT``, and it is load-bearing, not defensive: a future PR's +foreign keys that carry Django's collector down the criteria tree are all ``CASCADE``, so +deleting a ``Tag`` walks into its groups and then their criteria. This ``PROTECT`` is what turns +ADR-0002 Decision 7's guarantee into behavior at this level: the delete succeeds when no learner +holds status beneath the row and raises ``ProtectedError`` when one does. #675 re-implements the +same predicate at the API layer for a clean status code; this is the backstop for paths that +never reach it. + +``status`` is also ``on_delete=models.PROTECT``, because the lookup table it points to +(:class:`CompetencyMasteryStatus`) is system-owned immutable data, seeded by migration and never +deleted. +""" +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. + + Each member's value is the pinned primary key of its ``CompetencyMasteryStatus`` + row, seeded by the ``seed_competency_mastery_statuses`` data migration. + + The integer value of each member is also its rank, lowest to highest mastery. + Pinning the rank order into the stored id is what lets raising a learner's + status be written as one conditional ``UPDATE`` + (``... WHERE status_id < new_status_id``) instead of a read, a comparison in + Python, and a write: ADR-0004 Decision 4 requires that, because two concurrent + writers doing read-compare-write can each read the same old value, and the + later of the two writes then lowers what the earlier one had already raised. + + Because the ids are pinned, a new status can be added above or below the + existing three, but never between them. + + This subclasses ``IntegerChoices`` rather than ``enum.IntEnum`` so that + Django's migration serializer writes a member as a bare integer instead of an + import of this module. That keeps an already-applied migration's meaning + independent of later edits to this enum. + """ + + ATTEMPTED_NOT_DEMONSTRATED = 1, "AttemptedNotDemonstrated" + PARTIALLY_ATTEMPTED = 2, "PartiallyAttempted" + DEMONSTRATED = 3, "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: + """ + + # 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); see the module + docstring for the on_delete and timestamp rationale. + + .. 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 the in-place conditional + # update the module docstring describes: it is load-bearing, not a lookup + # optimisation. + models.UniqueConstraint( + fields=("user", "tag"), + name="oex_learning_studentcompetencystatus_user_tag_uniq", + ), + # Allow list, not a negation of the excluded value: a future fourth + # status should be 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", + ), + ] diff --git a/src/openedx_learning/migrations/0002_competency_mastery_status.py b/src/openedx_learning/migrations/0002_competency_mastery_status.py new file mode 100644 index 000000000..c59a09902 --- /dev/null +++ b/src/openedx_learning/migrations/0002_competency_mastery_status.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('openedx_learning', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CompetencyMasteryStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(max_length=64, unique=True)), + ], + ), + ] diff --git a/src/openedx_learning/migrations/0003_seed_competency_mastery_statuses.py b/src/openedx_learning/migrations/0003_seed_competency_mastery_statuses.py new file mode 100644 index 000000000..2f1a6fcfd --- /dev/null +++ b/src/openedx_learning/migrations/0003_seed_competency_mastery_statuses.py @@ -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=1, defaults={"status": "AttemptedNotDemonstrated"}) + CompetencyMasteryStatus.objects.get_or_create(id=2, defaults={"status": "PartiallyAttempted"}) + CompetencyMasteryStatus.objects.get_or_create(id=3, defaults={"status": "Demonstrated"}) + + +def revert(apps, schema_editor): # pragma: no cover + """ + Delete the seeded CompetencyMasteryStatus rows. + """ + CompetencyMasteryStatus = apps.get_model("openedx_learning", "CompetencyMasteryStatus") + CompetencyMasteryStatus.objects.filter(id__in=(1, 2, 3)).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("openedx_learning", "0002_competency_mastery_status"), + ] + + operations = [ + migrations.RunPython(forward, revert), + ] diff --git a/src/openedx_learning/migrations/0004_studentcompetencystatus.py b/src/openedx_learning/migrations/0004_studentcompetencystatus.py new file mode 100644 index 000000000..78934ff20 --- /dev/null +++ b/src/openedx_learning/migrations/0004_studentcompetencystatus.py @@ -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', '0003_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', (2, 3))), name='oex_learning_studentcompetencystatus_status_allowed')], + }, + ), + ] diff --git a/tests/openedx_learning/applets/cbe/test_learner_status.py b/tests/openedx_learning/applets/cbe/test_learner_status.py new file mode 100644 index 000000000..d24d0adde --- /dev/null +++ b/tests/openedx_learning/applets/cbe/test_learner_status.py @@ -0,0 +1,363 @@ +""" +Tests for the learner mastery status models: MasteryStatus, CompetencyMasteryStatus, and +StudentCompetencyStatus. + +The deletion tests below are part of this ticket's headline responsibility, not an afterthought: +`StudentCompetencyStatus.tag` (`on_delete=models.PROTECT`) is what stops Django's collector from +walking a `Tag` delete away for free, and `StudentCompetencyStatus.status` +(`on_delete=models.PROTECT`) does the same for the lookup table it points at. +""" +from datetime import datetime, timezone + +import pytest +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.db.models import ProtectedError + +from openedx_learning.models import CompetencyMasteryStatus, CompetencyTaxonomy, MasteryStatus, StudentCompetencyStatus +from openedx_tagging.models import Tag + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="competency_taxonomy") +def _competency_taxonomy() -> CompetencyTaxonomy: + """A CompetencyTaxonomy for use as a scope, and as the home taxonomy for `tag`.""" + return CompetencyTaxonomy.objects.create(name="Nursing", export_id="nursing-v1") + + +@pytest.fixture(name="tag") +def _tag(competency_taxonomy: CompetencyTaxonomy) -> Tag: + """A Tag, from `competency_taxonomy`, for use as the competency a learner is assessed on.""" + return Tag.objects.create(taxonomy=competency_taxonomy, value="Writing Poetry") + + +@pytest.fixture(name="other_tag") +def _other_tag(competency_taxonomy: CompetencyTaxonomy) -> Tag: + """A second Tag, from the same taxonomy as `tag`, for a second competency-level status.""" + return Tag.objects.create(taxonomy=competency_taxonomy, value="Decimals") + + +@pytest.fixture(name="user") +def _user(): + """ + Create a single learner for use in these tests. + + Deliberately unannotated: the user model is swappable, so this library must not + name a concrete one (edx-lint enforces that as `imported-auth-user`). + """ + 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) + + +# ============================================================================================== +# The lookup table and its rank ordering. +# ============================================================================================== + + +def test_seed_produces_three_rows_in_rank_order() -> None: + """ + The seed_competency_mastery_statuses data migration creates exactly the three + CompetencyMasteryStatus rows, with the pinned ids from MasteryStatus, and ids ascend in + rank (lowest to highest mastery). + """ + rows = list(CompetencyMasteryStatus.objects.order_by("id")) + assert [row.id for row in rows] == [ + MasteryStatus.ATTEMPTED_NOT_DEMONSTRATED, + MasteryStatus.PARTIALLY_ATTEMPTED, + MasteryStatus.DEMONSTRATED, + ] + assert [row.status for row in rows] == [ + MasteryStatus.ATTEMPTED_NOT_DEMONSTRATED.label, + MasteryStatus.PARTIALLY_ATTEMPTED.label, + MasteryStatus.DEMONSTRATED.label, + ] + + +def test_status_is_unique_on_lookup_table() -> None: + """ + CompetencyMasteryStatus.status is unique: a second row with a status string + that already exists raises IntegrityError. + """ + with pytest.raises(IntegrityError), transaction.atomic(): + CompetencyMasteryStatus.objects.create(status=MasteryStatus.DEMONSTRATED.label) + + +# ============================================================================================== +# The monotone conditional-update comparison, on StudentCompetencyStatus. +# ============================================================================================== + + +def test_conditional_raise_is_a_single_no_op_or_effective_update(user, tag: Tag, now: datetime) -> None: + """ + The monotone comparison works in a single statement on StudentCompetencyStatus: a + conditional UPDATE guarded by status_id__lt is a no-op against an already-higher status, + and is the one write that takes effect when the stored status is lower. + """ + scs = StudentCompetencyStatus.objects.create( + user=user, tag=tag, status_id=MasteryStatus.DEMONSTRATED, created=now, modified=now, + ) + + # Already at the top rank: an attempted raise to a lower/equal rank changes nothing. + changed = StudentCompetencyStatus.objects.filter( + user=user, tag=tag, status_id__lt=MasteryStatus.PARTIALLY_ATTEMPTED, + ).update(status_id=MasteryStatus.PARTIALLY_ATTEMPTED, modified=now) + assert changed == 0 + scs.refresh_from_db() + assert scs.status_id == MasteryStatus.DEMONSTRATED + + # Lower the stored status, then confirm the same shape raises it exactly once. + StudentCompetencyStatus.objects.filter(pk=scs.pk).update(status_id=MasteryStatus.PARTIALLY_ATTEMPTED) + changed = StudentCompetencyStatus.objects.filter( + user=user, tag=tag, status_id__lt=MasteryStatus.DEMONSTRATED, + ).update(status_id=MasteryStatus.DEMONSTRATED, modified=now) + assert changed == 1 + scs.refresh_from_db() + assert scs.status_id == MasteryStatus.DEMONSTRATED + + +# ============================================================================================== +# The allow-list check constraint on StudentCompetencyStatus. +# ============================================================================================== + + +def test_attempted_not_demonstrated_rejected_on_create(user, tag: Tag, now: datetime) -> None: + """ + The allow-list constraint rejects AttemptedNotDemonstrated on a direct create(). + """ + with pytest.raises(IntegrityError), transaction.atomic(): + StudentCompetencyStatus.objects.create( + user=user, + tag=tag, + status_id=MasteryStatus.ATTEMPTED_NOT_DEMONSTRATED, + created=now, + modified=now, + ) + + +def test_attempted_not_demonstrated_rejected_on_bulk_create(user, tag: Tag, now: datetime) -> None: + """ + The allow-list constraint rejects AttemptedNotDemonstrated on bulk_create(), which + bypasses Model.save() and full_clean(), so Python-side validation is skipped and only + the database-level check constraint still catches this. + """ + with pytest.raises(IntegrityError), transaction.atomic(): + StudentCompetencyStatus.objects.bulk_create([ + StudentCompetencyStatus( + user=user, + tag=tag, + status_id=MasteryStatus.ATTEMPTED_NOT_DEMONSTRATED, + created=now, + modified=now, + ) + ]) + + +def test_attempted_not_demonstrated_rejected_on_queryset_update(user, tag: Tag, now: datetime) -> None: + """ + The allow-list constraint also rejects AttemptedNotDemonstrated on QuerySet.update(), + the same write path the conditional raise above uses. + """ + scs = StudentCompetencyStatus.objects.create( + user=user, tag=tag, status_id=MasteryStatus.PARTIALLY_ATTEMPTED, created=now, modified=now, + ) + with pytest.raises(IntegrityError), transaction.atomic(): + StudentCompetencyStatus.objects.filter(pk=scs.pk).update( + status_id=MasteryStatus.ATTEMPTED_NOT_DEMONSTRATED, + ) + + +def test_demonstrated_and_partially_attempted_both_accepted( + user, tag: Tag, other_tag: Tag, now: datetime, +) -> None: + """ + Both statuses the allow-list permits, Demonstrated and PartiallyAttempted, are + accepted on create(). + """ + demonstrated = StudentCompetencyStatus.objects.create( + user=user, tag=tag, status_id=MasteryStatus.DEMONSTRATED, created=now, modified=now, + ) + partially_attempted = StudentCompetencyStatus.objects.create( + user=user, tag=other_tag, status_id=MasteryStatus.PARTIALLY_ATTEMPTED, created=now, modified=now, + ) + assert demonstrated.status_id == MasteryStatus.DEMONSTRATED + assert partially_attempted.status_id == MasteryStatus.PARTIALLY_ATTEMPTED + + +# ============================================================================================== +# One row per learner and tag (ADR-0002 Decision 5 index 8). +# ============================================================================================== + + +def test_one_row_per_user_and_tag_but_multiple_tags_per_user( + user, tag: Tag, other_tag: Tag, now: datetime, +) -> None: + """ + The (user, tag) unique constraint rejects a second row for the same pair, but + the same learner may hold a status for a different tag. + """ + StudentCompetencyStatus.objects.create( + user=user, tag=tag, status_id=MasteryStatus.PARTIALLY_ATTEMPTED, created=now, modified=now, + ) + with pytest.raises(IntegrityError), transaction.atomic(): + StudentCompetencyStatus.objects.create( + user=user, tag=tag, status_id=MasteryStatus.DEMONSTRATED, created=now, modified=now, + ) + + # A different tag for the same user is a different (user, tag) pair, so it's allowed. + other = StudentCompetencyStatus.objects.create( + user=user, tag=other_tag, status_id=MasteryStatus.DEMONSTRATED, created=now, modified=now, + ) + assert other.tag_id == other_tag.pk + + +# ============================================================================================== +# created/modified: caller-supplied UTC datetimes, not automatic. +# ============================================================================================== + + +def test_created_and_modified_are_required_and_must_be_utc(user, tag: Tag, now: datetime) -> None: + """ + created and modified are caller-supplied, not automatic: omitting either on create() + raises IntegrityError (NOT NULL, since there is no auto_now/auto_now_add default), and + passing a naive (non-UTC) datetime fails full_clean() with ValidationError, which is + the UTC validator manual_date_time_field() carries. + """ + with pytest.raises(IntegrityError), transaction.atomic(): + StudentCompetencyStatus.objects.create( + user=user, + tag=tag, + status_id=MasteryStatus.DEMONSTRATED, + ) + + naive_now = datetime.now() # deliberately naive, to trigger the UTC validator + scs = StudentCompetencyStatus( + user=user, + tag=tag, + status_id=MasteryStatus.DEMONSTRATED, + created=naive_now, + modified=now, + ) + with pytest.raises(ValidationError): + scs.full_clean() + + +def test_conditional_raise_can_carry_modified_without_touching_created(user, tag: Tag, now: datetime) -> None: + """ + A conditional raise can carry `modified` in the same UPDATE while leaving `created` + untouched: there is no `auto_now` to do it for them, deliberately, because `auto_now` + does not fire on `QuerySet.update()` and would silently leave the column stale on + exactly that path. + """ + scs = StudentCompetencyStatus.objects.create( + user=user, tag=tag, status_id=MasteryStatus.PARTIALLY_ATTEMPTED, created=now, modified=now, + ) + created_at = scs.created + + later = datetime.now(timezone.utc) + changed = StudentCompetencyStatus.objects.filter( + user=user, tag=tag, status_id__lt=MasteryStatus.DEMONSTRATED, + ).update(status_id=MasteryStatus.DEMONSTRATED, modified=later) + assert changed == 1 + + scs.refresh_from_db() + assert scs.status_id == MasteryStatus.DEMONSTRATED + assert scs.modified == later + assert scs.created == created_at + + +# ============================================================================================== +# No history package on StudentCompetencyStatus (ADR-0003 Decision 5). +# ============================================================================================== + + +def test_no_history_package_applied() -> None: + """ + StudentCompetencyStatus has no `history` attribute. + + ADR-0003 Decision 5 leaves how learner status history is retained undecided, so no history + package is applied to it. + """ + assert not hasattr(StudentCompetencyStatus, "history") + + +# ============================================================================================== +# Deletion. StudentCompetencyStatus.tag and .status are both PROTECT: neither a Tag nor a +# CompetencyMasteryStatus row can be deleted out from under a learner's recorded status. +# ============================================================================================== + + +def test_tag_delete_protected_by_competency_status_on_tag(tag: Tag, user, now: datetime) -> None: + """ + Deleting a Tag raises ProtectedError when a StudentCompetencyStatus row references it + directly via `tag` (`on_delete=models.PROTECT`). + """ + StudentCompetencyStatus.objects.create( + user=user, tag=tag, status_id=MasteryStatus.DEMONSTRATED, created=now, modified=now, + ) + + with pytest.raises(ProtectedError), transaction.atomic(): + tag.delete() + + assert Tag.objects.filter(pk=tag.pk).exists() + + +def test_taxonomy_delete_protected_by_competency_status_beneath_a_tag( + competency_taxonomy: CompetencyTaxonomy, tag: Tag, user, now: datetime, +) -> None: + """ + Deleting a CompetencyTaxonomy raises ProtectedError when a StudentCompetencyStatus row + exists on one of its tags, reached transitively: CompetencyTaxonomy -> Tag via + Tag.taxonomy (CASCADE, in openedx_tagging) -> the status row via + StudentCompetencyStatus.tag (PROTECT). Django evaluates PROTECT on every row the collector + reaches, not only the row passed to delete(), which is why the transitive case works. + """ + StudentCompetencyStatus.objects.create( + user=user, tag=tag, status_id=MasteryStatus.DEMONSTRATED, created=now, modified=now, + ) + + with pytest.raises(ProtectedError), transaction.atomic(): + competency_taxonomy.delete() + + assert CompetencyTaxonomy.objects.filter(pk=competency_taxonomy.pk).exists() + assert Tag.objects.filter(pk=tag.pk).exists() + + +def test_status_row_delete_blocked_when_referenced(tag: Tag, user, now: datetime) -> None: + """ + Deleting a CompetencyMasteryStatus row raises ProtectedError when a StudentCompetencyStatus + row references it via `status` (`on_delete=models.PROTECT`): the lookup table is system-owned + immutable data, and this is what stops a row from being deleted out from under a learner's + recorded status. + """ + scs = StudentCompetencyStatus.objects.create( + user=user, tag=tag, status_id=MasteryStatus.DEMONSTRATED, created=now, modified=now, + ) + + with pytest.raises(ProtectedError), transaction.atomic(): + scs.status.delete() + + assert CompetencyMasteryStatus.objects.filter(pk=MasteryStatus.DEMONSTRATED).exists() + + +def test_user_delete_removes_competency_status(user, tag: Tag, now: datetime) -> None: + """ + Deleting a User cascades to that learner's StudentCompetencyStatus row + (`on_delete=models.CASCADE` on its `user` foreign key), while leaving the tag itself + untouched: the status is a derived fact about the learner, so it goes when they do, but the + competency it was measuring does not. + """ + competency_status = StudentCompetencyStatus.objects.create( + user=user, tag=tag, status_id=MasteryStatus.DEMONSTRATED, created=now, modified=now, + ) + + user.delete() + + assert not StudentCompetencyStatus.objects.filter(pk=competency_status.pk).exists() + assert Tag.objects.filter(pk=tag.pk).exists()