Repository: xgeekshq/django-timestampable (language: python)

Issue:
Delete method not sending signals
When deleting an object it would be nice if it sends signals


Discussion / hints:
@dmp593 this issue is because you are not using `Collector` from django. This class is responsible to emit the signals.

Maybe the best approach is implement a `timestamps.models.collectors.SoftCollector` and (using as reference `django.db.models.deletion.Collector`) and use on the model:
```
def delete(self, using=None, keep_parents=False):
        if self.pk is None:
            raise ValueError(
                "%s object can't be deleted because its %s attribute is set "
                "to None." % (self._meta.object_name, self._meta.pk.attname)
            )
        using = using or router.db_for_write(self.__class__, instance=self)
        collector = SoftCollector(using=using, origin=self)
        collector.collect([self], keep_parents=keep_parents)
        return collector.delete()
        
def restore(self, using=None, keep_parents=False):
        if self.pk is None:
            raise ValueError(
                "%s object can't be deleted because its %s attribute is set "
                "to None." % (self._meta.object_name, self._meta.pk.attname)
            )
        using = using or router.db_for_write(self.__class__, instance=self)
        collector = SoftCollector(using=using, origin=self)
        collector.collect([self], keep_parents=keep_parents)
        return collector.restore()
```
 

Fix patch:
diff --git a/setup.cfg b/setup.cfg
index c0bc774..cb89add 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = django-timestampable
-version = 1.0.4
+version = 1.1.0
 description = Timestamps and Soft Delete Patterns in Django Models
 long_description = file: README.md
 long_description_content_type = text/markdown
@@ -14,6 +14,7 @@ classifiers =
     Framework :: Django :: 3.1
     Framework :: Django :: 3.2
     Framework :: Django :: 4.0
+    Framework :: Django :: 4.1
     Intended Audience :: Developers
     License :: OSI Approved :: MIT License
     Operating System :: OS Independent
@@ -25,6 +26,7 @@ classifiers =
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
+    Programming Language :: Python :: 3.11
     Topic :: Software Development :: Libraries
     Topic :: Software Development :: Libraries :: Application Frameworks
 
diff --git a/timestamps/managers.py b/timestamps/managers.py
index cf79260..803de10 100644
--- a/timestamps/managers.py
+++ b/timestamps/managers.py
@@ -19,6 +19,12 @@ def get_queryset(self):
 
     def delete(self, hard: bool = False):
         return self.get_queryset().delete(hard=hard)
+    
+    def soft_delete(self):
+        return self.delete(hard=False)
+    
+    def hard_delete(self):
+        return self.delete(hard=True)
 
     def restore(self):
         return self.get_queryset().restore()
diff --git a/timestamps/models.py b/timestamps/models.py
index 2feb9dd..479fef6 100644
--- a/timestamps/models.py
+++ b/timestamps/models.py
@@ -1,7 +1,7 @@
-from django.db import models
+from django.db import models, router
 from django.utils import timezone
 from .managers import SoftDeleteManager
-
+from . import signals
 
 class Timestampable(models.Model):
     created_at = models.DateTimeField(auto_now_add=True)
@@ -21,16 +21,40 @@ class SoftDeletes(models.Model):
     class Meta:
         abstract = True
 
-    def delete(self, using=None, keep_parents=False, hard: bool = False):
+    def delete(self, using=None, keep_parents: bool = False, hard: bool = False) -> None:
         if hard:
             return super().delete(using, keep_parents)
 
+        using = using or router.db_for_write(self.__class__, instance=self)
+
+        signals.pre_soft_delete.send(
+            sender=self.__class__,
+            instance=self,
+            using=using
+        )
+        
         self.deleted_at = timezone.now()
-        return self.save()
+        self.save()
+
+        signals.post_soft_delete.send(
+            sender=self.__class__,
+            instance=self,
+            using=using
+        )
+
+    def soft_delete(self) -> None:
+        self.delete(hard=False)
+    
+    def hard_delete(self, using=None, keep_parents: bool = False):
+        return self.delete(using, keep_parents, hard=True)
+
+    def restore(self) -> None:
+        signals.pre_restore.send(sender=self.__class__, instance=self)
 
-    def restore(self):
         self.deleted_at = None
-        return self.save()
+        self.save()
+
+        signals.post_restore.send(sender=self.__class__, instance=self)
 
 
 class Model(Timestampable, SoftDeletes, models.Model):
diff --git a/timestamps/signals.py b/timestamps/signals.py
new file mode 100644
index 0000000..bdba568
--- /dev/null
+++ b/timestamps/signals.py
@@ -0,0 +1,8 @@
+from django.dispatch import Signal
+
+
+pre_soft_delete = Signal()
+post_soft_delete = Signal()
+
+pre_restore = Signal()
+post_restore = Signal()


Test patch:
diff --git a/tests/tests/__init__.py b/tests/tests/__init__.py
index b74a2b6..ef9a934 100644
--- a/tests/tests/__init__.py
+++ b/tests/tests/__init__.py
@@ -2,3 +2,4 @@
 from .softdelete import *
 from .drf import *
 from .settings import *
+from .signals import *
\ No newline at end of file
diff --git a/tests/tests/signals.py b/tests/tests/signals.py
new file mode 100644
index 0000000..0ed77d3
--- /dev/null
+++ b/tests/tests/signals.py
@@ -0,0 +1,90 @@
+from django.test import TransactionTestCase
+from timestamps import signals
+
+from tests.models import FooSoftDeletes as Foo
+
+
+@Foo.fake_me
+class TestSoftDeleteSignal(TransactionTestCase):
+    def test_pre_soft_delete(self):
+        self.signaled = False
+
+        foo = Foo()
+        foo.save()
+
+        def handler(sender, instance, **kwargs):
+            self.signaled = True
+
+            self.assertEqual(sender, Foo)
+            self.assertIs(instance, foo)
+            self.assertIsNone(instance.deleted_at)
+
+        signals.pre_soft_delete.connect(handler)
+        
+        foo.soft_delete()
+        self.assertTrue(self.signaled)
+
+        signals.pre_soft_delete.disconnect(handler)
+
+    def test_post_soft_delete(self):
+        self.signaled = False
+
+        foo = Foo()
+        foo.save()
+
+        def handler(sender, instance, **kwargs):
+            self.signaled = True
+
+            self.assertEqual(sender, Foo)
+            self.assertIs(instance, foo)
+            self.assertIsNotNone(instance.deleted_at)
+
+        signals.post_soft_delete.connect(handler)
+        
+        foo.soft_delete()
+        self.assertTrue(self.signaled)
+
+        signals.post_soft_delete.disconnect(handler)
+
+
+    def test_pre_restore(self):
+        self.signaled = False
+
+        foo = Foo()
+        foo.save()
+        foo.soft_delete()
+
+        def handler(sender, instance, **kwargs):
+            self.signaled = True
+
+            self.assertEqual(sender, Foo)
+            self.assertIs(instance, foo)
+            self.assertIsNotNone(instance.deleted_at)
+
+        signals.pre_restore.connect(handler)
+        
+        foo.restore()
+        self.assertTrue(self.signaled)
+
+        signals.pre_restore.disconnect(handler)
+
+    def test_post_restore(self):
+        self.signaled = False
+
+        foo = Foo()
+        foo.save()
+        foo.soft_delete()
+
+        def handler(sender, instance, **kwargs):
+            self.signaled = True
+
+            self.assertEqual(sender, Foo)
+            self.assertIs(instance, foo)
+            self.assertIsNone(instance.deleted_at)
+
+        signals.post_restore.connect(handler)
+        
+        foo.restore()
+        self.assertTrue(self.signaled)
+
+        signals.post_restore.disconnect(handler)
diff --git a/tests/tests/softdelete.py b/tests/tests/softdelete.py
index 2a8d182..cf49e73 100644
--- a/tests/tests/softdelete.py
+++ b/tests/tests/softdelete.py
@@ -16,6 +16,42 @@ def test_soft_delete(self):
         # querying table again to check if object still exists
         self.assertEqual(1, Foo.objects_with_deleted.count())
 
+    def test_soft_delete_2(self):
+        foo = Foo()
+
+        foo.save()
+        self.assertIsNone(foo.deleted_at)
+
+        foo.soft_delete()
+        self.assertIsNotNone(foo.deleted_at)
+
+        # querying table again to check if object still exists
+        self.assertEqual(1, Foo.objects_with_deleted.count())
+
+    def test_hard_delete(self):
+        foo = Foo()
+
+        foo.save()
+        self.assertIsNone(foo.deleted_at)
+
+        foo.delete(hard=True)
+        self.assertIsNone(foo.deleted_at)
+
+        # querying table again to check if object still exists
+        self.assertEqual(0, Foo.objects_with_deleted.count())
+
+    def test_hard_delete_2(self):
+        foo = Foo()
+
+        foo.save()
+        self.assertIsNone(foo.deleted_at)
+
+        foo.hard_delete()
+        self.assertIsNone(foo.deleted_at)
+
+        # querying table again to check if object still exists
+        self.assertEqual(0, Foo.objects_with_deleted.count())
+
     def test_restore(self):
         foo = Foo()
         foo.save()
@@ -70,6 +106,16 @@ def test_bulk_soft_delete(self):
         self.assertEquals(0, Foo.objects.count())
         self.assertEquals(2, Foo.objects_deleted.count())
 
+    def test_bulk_soft_delete_2(self):
+        Foo().save()
+        Foo().save()
+
+        self.assertEquals(2, Foo.objects.count())
+
+        Foo.objects.soft_delete()
+        self.assertEquals(0, Foo.objects.count())
+        self.assertEquals(2, Foo.objects_deleted.count())
+
     def test_bulk_hard_delete(self):
         Foo().save()
         Foo().save()
@@ -79,6 +125,15 @@ def test_bulk_hard_delete(self):
         Foo.objects.delete(hard=True)
         self.assertEquals(0, Foo.objects_with_deleted.count())
 
+    def test_bulk_hard_delete_2(self):
+        Foo().save()
+        Foo().save()
+
+        self.assertEquals(2, Foo.objects.count())
+
+        Foo.objects.hard_delete()
+        self.assertEquals(0, Foo.objects_with_deleted.count())
+
     def test_bulk_restore(self):
         Foo().save()
         Foo().save()