diff --git a/src/openedx_tagging/migrations/0023_archived_and_deletion_locked.py b/src/openedx_tagging/migrations/0023_archived_and_deletion_locked.py new file mode 100644 index 000000000..a17445050 --- /dev/null +++ b/src/openedx_tagging/migrations/0023_archived_and_deletion_locked.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.17 on 2026-10-07 23:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oel_tagging', '0022_tag_external_id_not_null'), + ] + + operations = [ + migrations.AddField( + model_name='objecttag', + name='archived', + field=models.BooleanField(default=False, help_text='Hides this association from active use by default, without removing it.'), + ), + migrations.AddField( + model_name='objecttag', + name='deletion_locked', + field=models.BooleanField(default=False, help_text='Set when something outside the tagging app depends on this association; a delete archives it instead of removing it.'), + ), + migrations.AddField( + model_name='tag', + name='archived', + field=models.BooleanField(default=False, help_text='Hides this tag from active use by default, without removing it.'), + ), + migrations.AddField( + model_name='taxonomy', + name='archived', + field=models.BooleanField(default=False, help_text="Hides this taxonomy from active use by default, without removing it. Unlike disabling it with 'enabled', archiving marks the taxonomy as retired."), + ), + ] diff --git a/src/openedx_tagging/models/base.py b/src/openedx_tagging/models/base.py index f725ec778..e019d4b3c 100644 --- a/src/openedx_tagging/models/base.py +++ b/src/openedx_tagging/models/base.py @@ -84,6 +84,10 @@ class Tag(models.Model): " Set automatically by save(); do not set manually." ), ) + archived = models.BooleanField( + default=False, + help_text=_("Hides this tag from active use by default, without removing it."), + ) class Meta: indexes = [ @@ -311,6 +315,13 @@ class Taxonomy(models.Model): " taxonomy admins will not be permitted to add, edit, or delete its tags." ), ) + archived = models.BooleanField( + default=False, + help_text=_( + "Hides this taxonomy from active use by default, without removing it." + " Unlike disabling it with 'enabled', archiving marks the taxonomy as retired." + ), + ) class Meta: verbose_name_plural = "Taxonomies" @@ -724,6 +735,17 @@ class ObjectTag(models.Model): " If the tag field is set, then tag.value takes precedence over this field." ), ) + archived = models.BooleanField( + default=False, + help_text=_("Hides this association from active use by default, without removing it."), + ) + deletion_locked = models.BooleanField( + default=False, + help_text=_( + "Set when something outside the tagging app depends on this association;" + " a delete archives it instead of removing it." + ), + ) class Meta: indexes = [ @@ -896,6 +918,8 @@ def copy(self, object_tag: ObjectTag) -> Self: self.taxonomy = object_tag.taxonomy self.object_id = object_tag.object_id self.is_copied = object_tag.is_copied + self.archived = object_tag.archived + self.deletion_locked = object_tag.deletion_locked self._value = object_tag._value # pylint: disable=protected-access self._export_id = object_tag._export_id # pylint: disable=protected-access return self diff --git a/tests/openedx_tagging/test_migrations.py b/tests/openedx_tagging/test_migrations.py index ca1c5c906..7dd59e2c9 100644 --- a/tests/openedx_tagging/test_migrations.py +++ b/tests/openedx_tagging/test_migrations.py @@ -1,5 +1,5 @@ """ -Tests for the 0022_tag_external_id_not_null migration's backfill logic. +Tests that run openedx_tagging migrations over rows created on the earlier schema. Uses the `migrator` pytest fixture from django_test_migrations to run the actual migration against historical (frozen) model states, the same way it will run against @@ -12,6 +12,9 @@ MIGRATE_FROM = ("oel_tagging", "0021_remove_system_defined_add_read_only") MIGRATE_TO = ("oel_tagging", "0022_tag_external_id_not_null") +MIGRATE_0023_FROM = ("oel_tagging", "0022_tag_external_id_not_null") +MIGRATE_0023_TO = ("oel_tagging", "0023_archived_and_deletion_locked") + @pytest.mark.django_db def test_backfill_generates_missing_external_ids(migrator) -> None: @@ -98,3 +101,34 @@ def test_reverse_migration_is_a_noop_that_keeps_data(migrator) -> None: reverted_tag = RevertedTag.objects.get(pk=tag.pk) assert reverted_tag.external_id == "Rollback Tag" + + +@pytest.mark.django_db +def test_archived_and_deletion_locked_default_to_false_for_existing_rows(migrator) -> None: + """ + Rows that exist before 0023 come out unarchived and unlocked. + """ + old_state = migrator.apply_initial_migration(MIGRATE_0023_FROM) + Taxonomy = old_state.apps.get_model("oel_tagging", "Taxonomy") + Tag = old_state.apps.get_model("oel_tagging", "Tag") + ObjectTag = old_state.apps.get_model("oel_tagging", "ObjectTag") + + taxonomy = Taxonomy.objects.create(name="Flags Test", export_id="flags_test") + tag = Tag.objects.create( + taxonomy=taxonomy, value="Flags Tag", external_id="flags-tag", depth=0, lineage="Flags Tag\t", + ) + object_tag = ObjectTag.objects.create(object_id="object:id:1", taxonomy=taxonomy, tag=tag) + + new_state = migrator.apply_tested_migration(MIGRATE_0023_TO) + NewTaxonomy = new_state.apps.get_model("oel_tagging", "Taxonomy") + NewTag = new_state.apps.get_model("oel_tagging", "Tag") + NewObjectTag = new_state.apps.get_model("oel_tagging", "ObjectTag") + + new_taxonomy = NewTaxonomy.objects.get(pk=taxonomy.pk) + new_tag = NewTag.objects.get(pk=tag.pk) + new_object_tag = NewObjectTag.objects.get(pk=object_tag.pk) + + assert new_taxonomy.archived is False + assert new_tag.archived is False + assert new_object_tag.archived is False + assert new_object_tag.deletion_locked is False diff --git a/tests/openedx_tagging/test_models.py b/tests/openedx_tagging/test_models.py index abcdc9728..e95c61edc 100644 --- a/tests/openedx_tagging/test_models.py +++ b/tests/openedx_tagging/test_models.py @@ -613,6 +613,13 @@ def test_cast(self): == " object:id:1: Life on Earth=Bacteria" ) + def test_copying_a_tag_via_casting_copies_field_values(self) -> None: + self.object_tag.archived = True + self.object_tag.deletion_locked = True + copy_tag = ObjectTagTestSubclass.cast(self.object_tag) + assert copy_tag.archived is True + assert copy_tag.deletion_locked is True + def test_object_tag_export_id(self): # ObjectTag's export_id defaults to its taxonomy's export_id assert self.object_tag.export_id == self.taxonomy.export_id