diff --git a/src/mitol/common/changelog.d/20240108_173730_jkachel_move_soft_undelete_models_to_common.md b/src/mitol/common/changelog.d/20240108_173730_jkachel_move_soft_undelete_models_to_common.md new file mode 100644 index 00000000..6cb1dcc5 --- /dev/null +++ b/src/mitol/common/changelog.d/20240108_173730_jkachel_move_soft_undelete_models_to_common.md @@ -0,0 +1,42 @@ + + + + +### Added + +- Adds the SoftDeleteModel +- Adds ActiveUndeleteManager to support models that use SoftDeleteModel + + + + + diff --git a/src/mitol/common/models.py b/src/mitol/common/models.py index 39bb2366..e50a87bd 100644 --- a/src/mitol/common/models.py +++ b/src/mitol/common/models.py @@ -9,9 +9,11 @@ from django.db import transaction from django.db.models import ( PROTECT, + BooleanField, DateTimeField, ForeignKey, JSONField, + Manager, Model, prefetch_related_objects, ) @@ -47,6 +49,22 @@ class Meta: abstract = True +class SoftDeleteModel(Model): + """Model that blocks delete and instead marks the items is_active flag as false""" + + is_active = BooleanField(default=True) + + def delete(self): + """Mark items inactive instead of deleting them""" + self.is_active = False + self.save(update_fields=["is_active"]) + + class Meta: + """Meta options for SoftDeleteTimestampModel""" + + abstract = True + + class AuditModel(TimestampedModel): """An abstract base class for audit models""" @@ -235,3 +253,11 @@ def _clone(self): self._prefetch_generic_related_lookups ) return c + + +class ActiveUndeleteManager(Manager): + """Query manager for models with soft-delete that excludes inactive records""" + + def get_queryset(self): + """Get the active queryset for manager""" + return QuerySet(self.model, using=self._db).filter(is_active=True) diff --git a/tests/mitol/common/test_models.py b/tests/mitol/common/test_models.py index 33de9800..943c2f40 100644 --- a/tests/mitol/common/test_models.py +++ b/tests/mitol/common/test_models.py @@ -13,6 +13,8 @@ Root, SecondLevel1, SecondLevel2, + TestSoftDelete, + TestSoftDeleteTimestamped, Updateable, ) @@ -102,3 +104,33 @@ def test_auditable_model(): # auditable_instance.status = FinancialAidStatus.AUTO_APPROVED auditable_instance.save_and_log(user) assert AuditableTestModelAudit.objects.count() == 1 + + +def test_soft_delete_model(): + """Verify that soft delete models work""" + + soft_delete_record = TestSoftDelete.objects.create(test_data="Test Deletion") + + assert TestSoftDelete.objects.count() == 1 + + soft_delete_record.delete() + + assert TestSoftDelete.objects.count() == 0 + assert TestSoftDelete.all_objects.count() == 1 + + +def test_soft_delete_model_with_timestamps(): + """Verify that the updated_on timestamp gets updated too when deleting""" + + soft_delete_record = TestSoftDeleteTimestamped.objects.create( + test_data="Test Deletion" + ) + pk = soft_delete_record.id + + assert TestSoftDeleteTimestamped.objects.count() == 1 + + soft_delete_record.delete() + + soft_delete_record = TestSoftDeleteTimestamped.all_objects.get(pk=pk) + + assert soft_delete_record.created_on != soft_delete_record.updated_on diff --git a/tests/testapp/migrations/0004_add_soft_delete_models_update_ids.py b/tests/testapp/migrations/0004_add_soft_delete_models_update_ids.py new file mode 100644 index 00000000..7e4d8217 --- /dev/null +++ b/tests/testapp/migrations/0004_add_soft_delete_models_update_ids.py @@ -0,0 +1,123 @@ +# Generated by Django 4.2.8 on 2024-01-08 17:19 + +import django.db.models.manager +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("testapp", "0003_auditabletestmodelaudit"), + ] + + operations = [ + migrations.CreateModel( + name="TestSoftDelete", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("is_active", models.BooleanField(default=True)), + ("test_data", models.CharField(max_length=100)), + ], + options={ + "abstract": False, + }, + managers=[ + ("all_objects", django.db.models.manager.Manager()), + ], + ), + migrations.CreateModel( + name="TestSoftDeleteTimestamped", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("is_active", models.BooleanField(default=True)), + ("test_data", models.CharField(max_length=100)), + ], + options={ + "abstract": False, + }, + managers=[ + ("all_objects", django.db.models.manager.Manager()), + ], + ), + migrations.AlterField( + model_name="auditabletestmodel", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="auditabletestmodelaudit", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="democourseware", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="firstlevel1", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="firstlevel2", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="root", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="secondlevel1", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="secondlevel2", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="updateable", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ] diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 66d2e5ea..f7cf7bd2 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -5,9 +5,11 @@ from django.db import models from mitol.common.models import ( + ActiveUndeleteManager, AuditableModel, AuditModel, PrefetchGenericQuerySet, + SoftDeleteModel, TimestampedModel, ) from mitol.common.utils.serializers import serialize_model_object @@ -85,3 +87,21 @@ class DemoCourseware(models.Model): description = models.TextField() learner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + +class TestSoftDelete(SoftDeleteModel): + """Test model for soft deletion""" + + test_data = models.CharField(max_length=100) + + all_objects = models.Manager() + objects = ActiveUndeleteManager() + + +class TestSoftDeleteTimestamped(SoftDeleteModel, TimestampedModel): + """Test model for soft deletions with timestamps""" + + test_data = models.CharField(max_length=100) + + all_objects = models.Manager() + objects = ActiveUndeleteManager()