├── tests ├── __init__.py ├── fixtures │ ├── __init__.py │ ├── bars.json │ └── foos.json ├── tests │ ├── __init__.py │ ├── timestamps.py │ ├── settings.py │ ├── signals.py │ ├── softdelete.py │ └── drf.py ├── viewsets.py ├── views.py ├── serializers.py ├── urls.py ├── models.py └── settings.py ├── timestamps ├── __init__.py ├── apps.py ├── signals.py ├── drf │ ├── __init__.py │ ├── utils.py │ ├── permissions.py │ ├── viewsets.py │ ├── routers.py │ └── mixins.py ├── querysets.py ├── managers.py └── models.py ├── MANIFEST.in ├── bmc_qr.png ├── requirements.txt ├── setup.py ├── manage.py ├── Makefile ├── LICENSE ├── setup.cfg ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /timestamps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include docs * -------------------------------------------------------------------------------- /bmc_qr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xgeekshq/django-timestampable/HEAD/bmc_qr.png -------------------------------------------------------------------------------- /timestamps/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TimestampsConfig(AppConfig): 5 | name = 'timestamps' 6 | -------------------------------------------------------------------------------- /tests/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .timestamps import * 2 | from .softdelete import * 3 | from .drf import * 4 | from .settings import * 5 | from .signals import * -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools>=50.0 2 | Django>=3.1 3 | aspectlib>=1.5 4 | djangorestframework>=3.12 5 | django-fake-model>=0.1.4 6 | django-extensions>=3.1 7 | coverage>=5.5 8 | twine>=3.4 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | extras_require={ 6 | "drf": [ 7 | "djangorestframework>=3.12" 8 | ] 9 | } 10 | ) 11 | -------------------------------------------------------------------------------- /timestamps/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | pre_soft_delete = Signal() 5 | post_soft_delete = Signal() 6 | 7 | pre_restore = Signal() 8 | post_restore = Signal() 9 | -------------------------------------------------------------------------------- /tests/viewsets.py: -------------------------------------------------------------------------------- 1 | from timestamps.drf.viewsets import ModelViewSet 2 | from .models import Foo 3 | from .serializers import FooSerializer 4 | 5 | 6 | class FooViewSet(ModelViewSet): 7 | queryset = Foo.objects.all() 8 | serializer_class = FooSerializer 9 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics 2 | from .models import Bar 3 | from .serializers import BarSerializer 4 | 5 | 6 | class BarRetrieveAPIView(generics.RetrieveAPIView): 7 | queryset = Bar.objects.all() 8 | serializer_class = BarSerializer 9 | -------------------------------------------------------------------------------- /tests/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from .models import Foo, Bar 3 | 4 | 5 | class FooSerializer(serializers.ModelSerializer): 6 | class Meta: 7 | model = Foo 8 | exclude = ['deleted_at'] 9 | 10 | 11 | class BarSerializer(serializers.ModelSerializer): 12 | class Meta: 13 | model = Bar 14 | exclude = ['deleted_at'] 15 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from timestamps.drf import routers 3 | from .viewsets import FooViewSet 4 | from .views import BarRetrieveAPIView 5 | 6 | 7 | router = routers.DefaultRouter() 8 | router.register(r'foos', FooViewSet, basename='foos') 9 | 10 | foo_urls = router.urls 11 | bar_urls = [ 12 | path('bars//', BarRetrieveAPIView.as_view()), 13 | ] 14 | 15 | urlpatterns = foo_urls + bar_urls 16 | -------------------------------------------------------------------------------- /timestamps/drf/__init__.py: -------------------------------------------------------------------------------- 1 | def __require_djangorestframework(): 2 | try: 3 | import rest_framework 4 | except ImportError as e: 5 | raise ImportError( 6 | 'djangorestframework not installed. ' 7 | 'run `pip install django-timestampable[drf]` ' 8 | 'and add "rest_framework" to your INSTALLED_APPS') \ 9 | from e 10 | 11 | 12 | __require_djangorestframework() 13 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from timestamps.models import models, Model 3 | 4 | 5 | class Foo(Model): 6 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 7 | name = models.CharField(max_length=255) 8 | 9 | 10 | class Bar(Model): 11 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 12 | name = models.CharField(max_length=255) 13 | foo = models.ForeignKey(Foo, on_delete=models.CASCADE) 14 | -------------------------------------------------------------------------------- /timestamps/drf/utils.py: -------------------------------------------------------------------------------- 1 | from rest_framework.serializers import BooleanField 2 | from rest_framework.views import View 3 | from .permissions import can_hard_delete 4 | 5 | 6 | def is_hard_delete_request(view: View) -> bool: 7 | permanent = view.request.query_params.get('permanent') 8 | is_hard_delete = BooleanField(required=False, allow_null=True).run_validation(permanent) 9 | 10 | if is_hard_delete: 11 | can_hard_delete(view) 12 | 13 | return is_hard_delete 14 | -------------------------------------------------------------------------------- /timestamps/drf/permissions.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework.exceptions import PermissionDenied 3 | 4 | 5 | def can_hard_delete(view): 6 | action = getattr(view, 'action', None) 7 | 8 | if action is None: 9 | from timestamps.drf.mixins import BulkDestroyModelMixin 10 | 11 | if issubclass(view, BulkDestroyModelMixin): 12 | action = 'bulk_destroy' 13 | 14 | if action == 'bulk_destroy' and not getattr(settings, 'TIMESTAMPS__BULK_HARD_DELETE', False): 15 | raise PermissionDenied() 16 | -------------------------------------------------------------------------------- /timestamps/querysets.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | from django.utils import timezone 3 | 4 | 5 | class SoftDeleteQuerySet(QuerySet): 6 | def only_deleted(self): 7 | return self.filter(deleted_at__isnull=False) 8 | 9 | def without_deleted(self): 10 | return self.filter(deleted_at__isnull=True) 11 | 12 | # bulk deleting 13 | def delete(self, hard: bool = False): 14 | if hard: 15 | return super(SoftDeleteQuerySet, self).delete() 16 | 17 | return super(SoftDeleteQuerySet, self).update(deleted_at=timezone.now()) 18 | 19 | # bulk restore 20 | def restore(self): 21 | return super(SoftDeleteQuerySet, self).update(deleted_at=None) 22 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = '!fake' 2 | 3 | DEBUG = True 4 | 5 | INSTALLED_APPS = [ 6 | 'django.contrib.auth', 7 | 'django.contrib.contenttypes', 8 | 'django.contrib.sessions', 9 | 'django.contrib.messages', 10 | 'django.contrib.staticfiles', 11 | 12 | 'django_extensions', 13 | 'rest_framework', 14 | 15 | 'timestamps', 16 | 'tests' 17 | ] 18 | 19 | ROOT_URLCONF = 'tests.urls' 20 | 21 | DATABASES = { 22 | 'default': { 23 | 'ENGINE': 'django.db.backends.sqlite3', 24 | 'NAME': ':memory:', 25 | } 26 | } 27 | 28 | TIME_ZONE = 'UTC' 29 | USE_I18N = True 30 | USE_L10N = True 31 | USE_TZ = True 32 | 33 | TIMESTAMPS__BULK_HARD_DELETE = True 34 | TIMESTAMPS__BULK_RESPONSE_CONTENT = True 35 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | requirements: requirements.txt 2 | python3 -m venv .venv 3 | ./.venv/bin/pip install -r requirements.txt 4 | 5 | clean: 6 | find . -type f -name '*.pyc' -delete 7 | rm -rf .venv 8 | 9 | test: 10 | ./.venv/bin/python manage.py test tests 11 | 12 | coverage: 13 | ./.venv/bin/coverage run --source='timestamps' manage.py test tests 14 | ./.venv/bin/coverage report -m 15 | 16 | show-urls: 17 | ./.venv/bin/python manage.py show_urls 18 | 19 | run: clean requirements test 20 | 21 | build: run 22 | ./.venv/bin/python setup.py sdist 23 | 24 | pypi: build 25 | ./.venv/bin/python -m twine upload dist/* --config-file ~/.pypirc 26 | 27 | test-pypi: build 28 | ./.venv/bin/python -m twine upload --verbose --repository testpypi dist/* --config-file ~/.pypirc 29 | -------------------------------------------------------------------------------- /timestamps/drf/viewsets.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | 3 | from .mixins import ( 4 | ListDeletedModelMixin, 5 | ListWithDeletedModelMixin, 6 | RetrieveDeletedModelMixin, 7 | RetrieveWithDeletedModelMixin, 8 | RestoreModelMixin, 9 | BulkRestoreModelMixin, 10 | DestroyModelMixin, 11 | BulkDestroyModelMixin 12 | ) 13 | 14 | 15 | class ModelViewSet(ListDeletedModelMixin, 16 | ListWithDeletedModelMixin, 17 | RetrieveDeletedModelMixin, 18 | RetrieveWithDeletedModelMixin, 19 | RestoreModelMixin, 20 | BulkRestoreModelMixin, 21 | DestroyModelMixin, 22 | BulkDestroyModelMixin, 23 | viewsets.ModelViewSet): 24 | pass 25 | -------------------------------------------------------------------------------- /tests/fixtures/bars.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "tests.Bar", 4 | "pk": "14e6c142-580e-4c32-815c-ee347b03b567", 5 | "fields": { 6 | "name": "megabar", 7 | "foo": "32a6fab2-9e0f-4d23-9a3b-c642470e629d", 8 | "created_at": "2021-01-01T00:00:00.000Z", 9 | "updated_at": "2021-01-01T00:00:00.000Z", 10 | "deleted_at": null 11 | } 12 | }, 13 | { 14 | "model": "tests.Bar", 15 | "pk": "6ee25ce6-4c53-4c7a-9283-078b11e8fe97", 16 | "fields": { 17 | "name": "superbar", 18 | "foo": "381b0e65-bc13-43de-b216-8673c18aa645", 19 | "created_at": "2021-01-01T00:00:00.000Z", 20 | "updated_at": "2021-01-01T00:00:00.000Z", 21 | "deleted_at": "2021-01-01T13:00:00.000Z" 22 | } 23 | } 24 | ] -------------------------------------------------------------------------------- /tests/tests/timestamps.py: -------------------------------------------------------------------------------- 1 | from django.test import TransactionTestCase 2 | from tests.models import Foo 3 | 4 | 5 | class TimestampableTestCase(TransactionTestCase): 6 | def test_created_at_is_set_on_create(self): 7 | f = Foo() 8 | self.assertIsNone(f.created_at) 9 | 10 | f.save() 11 | self.assertIsNotNone(f.created_at) 12 | 13 | def test_updated_at_is_set_on_create(self): 14 | f = Foo() 15 | self.assertIsNone(f.updated_at) 16 | 17 | f.save() 18 | self.assertIsNotNone(f.updated_at) 19 | 20 | def test_updated_at_is_set_on_update(self): 21 | f = Foo() 22 | f.save() 23 | 24 | updated_at = f.updated_at 25 | 26 | f.save() 27 | self.assertNotEqual(updated_at, f.updated_at) 28 | 29 | def test_created_at_is_not_set_on_update(self): 30 | f = Foo() 31 | f.save() 32 | 33 | created_at = f.created_at 34 | 35 | f.save() 36 | self.assertEqual(created_at, f.created_at) 37 | -------------------------------------------------------------------------------- /timestamps/managers.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Manager 2 | from .querysets import SoftDeleteQuerySet 3 | 4 | 5 | class SoftDeleteManager(Manager): 6 | def __init__(self, *args, **kwargs): 7 | self.with_deleted = kwargs.pop('with_deleted', False) 8 | self.only_deleted = kwargs.pop('only_deleted', False) 9 | super(SoftDeleteManager, self).__init__(*args, **kwargs) 10 | 11 | def get_queryset(self): 12 | if self.with_deleted: 13 | return SoftDeleteQuerySet(self.model) 14 | 15 | if self.only_deleted: 16 | return SoftDeleteQuerySet(self.model).only_deleted() 17 | 18 | return SoftDeleteQuerySet(self.model).without_deleted() 19 | 20 | def delete(self, hard: bool = False): 21 | return self.get_queryset().delete(hard=hard) 22 | 23 | def soft_delete(self): 24 | return self.delete(hard=False) 25 | 26 | def hard_delete(self): 27 | return self.delete(hard=True) 28 | 29 | def restore(self): 30 | return self.get_queryset().restore() 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 xgeeks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/fixtures/foos.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "tests.foo", 4 | "pk": "32a6fab2-9e0f-4d23-9a3b-c642470e629d", 5 | "fields": { 6 | "name": "Reprehenderit nesciunt delectus inventore iste culpa voluptate fuga doloremque voluptas maxime", 7 | "created_at": "2021-01-01T00:00:00.000Z", 8 | "updated_at": "2021-01-01T00:00:00.000Z", 9 | "deleted_at": null 10 | } 11 | }, 12 | { 13 | "model": "tests.foo", 14 | "pk": "0133c730-ed24-445e-9a61-c4bd033cd32f", 15 | "fields": { 16 | "name": "Cum neque unde reprehenderit nam sint ea consectetur quo reiciendis, vero labore impedit hic nisi", 17 | "created_at": "2021-01-01T00:00:00.000Z", 18 | "updated_at": "2021-01-01T00:00:00.000Z", 19 | "deleted_at": null 20 | } 21 | }, 22 | { 23 | "model": "tests.foo", 24 | "pk": "d1c33a7a-4c59-4fe6-9e96-ab6f8ec2d120", 25 | "fields": { 26 | "name": "Non aperiam dolorem maiores cupiditate delectus quos, perferendis quas beatae ipsa cumque dolores natus.", 27 | "created_at": "2021-01-01T00:00:00.000Z", 28 | "updated_at": "2021-01-01T00:00:00.000Z", 29 | "deleted_at": null 30 | } 31 | }, 32 | { 33 | "model": "tests.foo", 34 | "pk": "381b0e65-bc13-43de-b216-8673c18aa645", 35 | "fields": { 36 | "name": "Facilis deleniti maiores a facere minus dolor pariatur, excepturi odit impedit officia sit fuga ipsa inventore possimus, rerum blanditiis voluptate?", 37 | "created_at": "2021-01-01T00:00:00.000Z", 38 | "updated_at": "2021-01-01T00:00:00.000Z", 39 | "deleted_at": "2021-01-01T13:00:00.000Z" 40 | } 41 | } 42 | ] -------------------------------------------------------------------------------- /tests/tests/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import TestCase 3 | from rest_framework import status 4 | 5 | 6 | class SettingsTestCase(TestCase): 7 | fixtures = ['foos'] 8 | 9 | def test_bulk_delete_response_no_content(self): 10 | setattr(settings, 'TIMESTAMPS__BULK_RESPONSE_CONTENT', False) 11 | response = self.client.delete('/foos/') 12 | self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) 13 | 14 | def test_bulk_restore_response_no_content(self): 15 | setattr(settings, 'TIMESTAMPS__BULK_RESPONSE_CONTENT', False) 16 | response = self.client.patch('/foos/restore/') 17 | self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) 18 | 19 | def test_permission_denied_bulk_hard_delete(self): 20 | setattr(settings, 'TIMESTAMPS__BULK_HARD_DELETE', False) 21 | response = self.client.delete('/foos/?permanent=1') 22 | self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) 23 | 24 | def test_can_bulk_hard_delete(self): 25 | setattr(settings, 'TIMESTAMPS__BULK_HARD_DELETE', True) 26 | setattr(settings, 'TIMESTAMPS__BULK_RESPONSE_CONTENT', True) 27 | 28 | response = self.client.delete('/foos/?permanent=1') 29 | self.assertEqual(status.HTTP_200_OK, response.status_code) 30 | 31 | response_data = response.json() 32 | self.assertEqual(4, response_data.get('count')) 33 | 34 | self.assertIn('count_per_model', response_data) 35 | self.assertIn('tests.Foo', response_data.get('count_per_model')) 36 | self.assertEqual(4, response_data['count_per_model']['tests.Foo']) 37 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-timestampable 3 | version = 1.1.5 4 | description = Timestamps and Soft Delete Patterns in Django Models 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/xgeekshq/django-timestampable/ 8 | author = Daniel Pinto 9 | author_email = dmp593@gmail.com 10 | license = MIT 11 | classifiers = 12 | Environment :: Web Environment 13 | Framework :: Django 14 | Framework :: Django :: 3.1 15 | Framework :: Django :: 3.2 16 | Framework :: Django :: 4.0 17 | Framework :: Django :: 4.1 18 | Framework :: Django :: 4.2 19 | Framework :: Django :: 5.0 20 | Framework :: Django :: 5.1 21 | Framework :: Django :: 5.2 22 | Intended Audience :: Developers 23 | License :: OSI Approved :: MIT License 24 | Operating System :: OS Independent 25 | Programming Language :: Python 26 | Programming Language :: Python :: 3 27 | Programming Language :: Python :: 3 :: Only 28 | Programming Language :: Python :: 3.6 29 | Programming Language :: Python :: 3.7 30 | Programming Language :: Python :: 3.8 31 | Programming Language :: Python :: 3.9 32 | Programming Language :: Python :: 3.10 33 | Programming Language :: Python :: 3.11 34 | Programming Language :: Python :: 3.12 35 | Programming Language :: Python :: 3.13 36 | Topic :: Software Development :: Libraries 37 | Topic :: Software Development :: Libraries :: Application Frameworks 38 | 39 | [options] 40 | include_package_data = true 41 | packages = 42 | timestamps 43 | timestamps.drf 44 | python_requires = >=3.6 45 | setup_requires = 46 | setuptools >= 54.1 47 | install_requires = 48 | Django>=3.1 49 | aspectlib>=1.5 50 | -------------------------------------------------------------------------------- /timestamps/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models, router 2 | from django.utils import timezone 3 | from django.utils.translation import gettext_lazy as _ 4 | from .managers import SoftDeleteManager 5 | from . import signals 6 | 7 | class Timestampable(models.Model): 8 | created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("created_at")) 9 | updated_at = models.DateTimeField(auto_now=True, verbose_name=_("updated_at")) 10 | 11 | class Meta: 12 | abstract = True 13 | 14 | 15 | class SoftDeletes(models.Model): 16 | deleted_at = models.DateTimeField(null=True, blank=True, verbose_name=_("deleted_at")) 17 | 18 | objects = SoftDeleteManager() 19 | objects_deleted = SoftDeleteManager(only_deleted=True) 20 | objects_with_deleted = SoftDeleteManager(with_deleted=True) 21 | 22 | class Meta: 23 | abstract = True 24 | 25 | def delete(self, using=None, keep_parents: bool = False, hard: bool = False) -> None: 26 | if hard: 27 | return super().delete(using, keep_parents) 28 | 29 | using = using or router.db_for_write(self.__class__, instance=self) 30 | 31 | signals.pre_soft_delete.send( 32 | sender=self.__class__, 33 | instance=self, 34 | using=using 35 | ) 36 | 37 | self.deleted_at = timezone.now() 38 | self.save() 39 | 40 | signals.post_soft_delete.send( 41 | sender=self.__class__, 42 | instance=self, 43 | using=using 44 | ) 45 | 46 | def soft_delete(self) -> None: 47 | self.delete(hard=False) 48 | 49 | def hard_delete(self, using=None, keep_parents: bool = False): 50 | return self.delete(using, keep_parents, hard=True) 51 | 52 | def restore(self) -> None: 53 | signals.pre_restore.send(sender=self.__class__, instance=self) 54 | 55 | self.deleted_at = None 56 | self.save() 57 | 58 | signals.post_restore.send(sender=self.__class__, instance=self) 59 | 60 | 61 | class Model(Timestampable, SoftDeletes, models.Model): 62 | class Meta: 63 | abstract = True 64 | -------------------------------------------------------------------------------- /timestamps/drf/routers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import routers 2 | 3 | 4 | class DefaultRouter(routers.DefaultRouter): 5 | def __init__(self, *args, **kwargs): 6 | super(DefaultRouter, self).__init__(*args, **kwargs) 7 | 8 | routers.DefaultRouter.routes[0].mapping.setdefault('delete', 'bulk_destroy') 9 | 10 | # list deleted 11 | routers.DefaultRouter.routes.insert(0, routers.Route( 12 | url=r'^{prefix}/deleted{trailing_slash}$', 13 | mapping={ 14 | 'get': 'list_deleted', 15 | }, 16 | name='{basename}-list-deleted', 17 | detail=False, 18 | initkwargs={'suffix': 'Deleted'} 19 | )) 20 | 21 | # list with deleted 22 | routers.DefaultRouter.routes.insert(1, routers.Route( 23 | url=r'^{prefix}/with-deleted{trailing_slash}$', 24 | mapping={ 25 | 'get': 'list_with_deleted', 26 | }, 27 | name='{basename}-list-with-deleted', 28 | detail=False, 29 | initkwargs={'suffix': 'With Deleted'} 30 | )) 31 | 32 | # bulk restore 33 | routers.DefaultRouter.routes.insert(2, routers.Route( 34 | url=r'^{prefix}/restore{trailing_slash}$', 35 | mapping={ 36 | 'patch': 'bulk_restore', 37 | }, 38 | name='{basename}-list-restore', 39 | detail=False, 40 | initkwargs={'suffix': 'Restore'} 41 | )) 42 | 43 | # retrieve deleted 44 | routers.DefaultRouter.routes.insert(5, routers.Route( 45 | url=r'^{prefix}/deleted/{lookup}{trailing_slash}$', 46 | name='{basename}-deleted', 47 | mapping={ 48 | 'get': 'retrieve_deleted', 49 | }, 50 | detail=True, 51 | initkwargs={'suffix': 'Deleted'} 52 | )) 53 | 54 | # retrieve with deleted 55 | routers.DefaultRouter.routes.insert(6, routers.Route( 56 | url=r'^{prefix}/with-deleted/{lookup}{trailing_slash}$', 57 | name='{basename}-with-deleted', 58 | mapping={ 59 | 'get': 'retrieve_with_deleted', 60 | }, 61 | detail=True, 62 | initkwargs={'suffix': 'With Deleted'} 63 | )) 64 | 65 | # restore 66 | routers.DefaultRouter.routes.insert(7, routers.Route( 67 | url=r'^{prefix}/{lookup}/restore{trailing_slash}$', 68 | name='{basename}-restore', 69 | mapping={ 70 | 'patch': 'restore', 71 | }, 72 | detail=True, 73 | initkwargs={'suffix': 'Restore'} 74 | )) 75 | -------------------------------------------------------------------------------- /tests/tests/signals.py: -------------------------------------------------------------------------------- 1 | from django.test import TransactionTestCase 2 | from timestamps import signals 3 | 4 | from tests.models import Foo 5 | 6 | 7 | class TestSoftDeleteSignal(TransactionTestCase): 8 | def test_pre_soft_delete(self): 9 | self.signaled = False 10 | 11 | foo = Foo() 12 | foo.save() 13 | 14 | def handler(sender, instance, **kwargs): 15 | self.signaled = True 16 | 17 | self.assertEqual(sender, Foo) 18 | self.assertIs(instance, foo) 19 | self.assertIsNone(instance.deleted_at) 20 | 21 | signals.pre_soft_delete.connect(handler) 22 | 23 | foo.soft_delete() 24 | self.assertTrue(self.signaled) 25 | 26 | signals.pre_soft_delete.disconnect(handler) 27 | 28 | def test_post_soft_delete(self): 29 | self.signaled = False 30 | 31 | foo = Foo() 32 | foo.save() 33 | 34 | def handler(sender, instance, **kwargs): 35 | self.signaled = True 36 | 37 | self.assertEqual(sender, Foo) 38 | self.assertIs(instance, foo) 39 | self.assertIsNotNone(instance.deleted_at) 40 | 41 | signals.post_soft_delete.connect(handler) 42 | 43 | foo.soft_delete() 44 | self.assertTrue(self.signaled) 45 | 46 | signals.post_soft_delete.disconnect(handler) 47 | 48 | 49 | def test_pre_restore(self): 50 | self.signaled = False 51 | 52 | foo = Foo() 53 | foo.save() 54 | foo.soft_delete() 55 | 56 | def handler(sender, instance, **kwargs): 57 | self.signaled = True 58 | 59 | self.assertEqual(sender, Foo) 60 | self.assertIs(instance, foo) 61 | self.assertIsNotNone(instance.deleted_at) 62 | 63 | signals.pre_restore.connect(handler) 64 | 65 | foo.restore() 66 | self.assertTrue(self.signaled) 67 | 68 | signals.pre_restore.disconnect(handler) 69 | 70 | def test_post_restore(self): 71 | self.signaled = False 72 | 73 | foo = Foo() 74 | foo.save() 75 | foo.soft_delete() 76 | 77 | def handler(sender, instance, **kwargs): 78 | self.signaled = True 79 | 80 | self.assertEqual(sender, Foo) 81 | self.assertIs(instance, foo) 82 | self.assertIsNone(instance.deleted_at) 83 | 84 | signals.post_restore.connect(handler) 85 | 86 | foo.restore() 87 | self.assertTrue(self.signaled) 88 | 89 | signals.post_restore.disconnect(handler) 90 | -------------------------------------------------------------------------------- /tests/tests/softdelete.py: -------------------------------------------------------------------------------- 1 | from django.test import TransactionTestCase 2 | from tests.models import Foo 3 | 4 | 5 | class SoftDeletesTestCase(TransactionTestCase): 6 | def test_soft_delete(self): 7 | foo = Foo() 8 | 9 | foo.save() 10 | self.assertIsNone(foo.deleted_at) 11 | 12 | foo.delete() 13 | self.assertIsNotNone(foo.deleted_at) 14 | 15 | # querying table again to check if object still exists 16 | self.assertEqual(1, Foo.objects_with_deleted.count()) 17 | 18 | def test_soft_delete_2(self): 19 | foo = Foo() 20 | 21 | foo.save() 22 | self.assertIsNone(foo.deleted_at) 23 | 24 | foo.soft_delete() 25 | self.assertIsNotNone(foo.deleted_at) 26 | 27 | # querying table again to check if object still exists 28 | self.assertEqual(1, Foo.objects_with_deleted.count()) 29 | 30 | def test_hard_delete(self): 31 | foo = Foo() 32 | 33 | foo.save() 34 | self.assertIsNone(foo.deleted_at) 35 | 36 | foo.delete(hard=True) 37 | self.assertIsNone(foo.deleted_at) 38 | 39 | # querying table again to check if object still exists 40 | self.assertEqual(0, Foo.objects_with_deleted.count()) 41 | 42 | def test_hard_delete_2(self): 43 | foo = Foo() 44 | 45 | foo.save() 46 | self.assertIsNone(foo.deleted_at) 47 | 48 | foo.hard_delete() 49 | self.assertIsNone(foo.deleted_at) 50 | 51 | # querying table again to check if object still exists 52 | self.assertEqual(0, Foo.objects_with_deleted.count()) 53 | 54 | def test_restore(self): 55 | foo = Foo() 56 | foo.save() 57 | 58 | foo.delete() 59 | self.assertIsNotNone(foo.deleted_at) 60 | 61 | self.assertEqual(0, Foo.objects.count()) 62 | 63 | foo.restore() 64 | self.assertEqual(1, Foo.objects.count()) 65 | 66 | def test_querysets_count(self): 67 | foo1 = Foo() 68 | foo1.save() 69 | 70 | self.assertEqual(1, Foo.objects.count()) 71 | self.assertEqual(0, Foo.objects_deleted.count()) 72 | self.assertEqual(1, Foo.objects_with_deleted.count()) 73 | 74 | foo1.delete(hard=False) 75 | self.assertEqual(0, Foo.objects.count()) 76 | self.assertEqual(1, Foo.objects_deleted.count()) 77 | self.assertEqual(1, Foo.objects_with_deleted.count()) 78 | 79 | foo1.restore() 80 | 81 | foo2 = Foo() 82 | foo2.save() 83 | 84 | self.assertEqual(2, Foo.objects.count()) 85 | self.assertEqual(0, Foo.objects_deleted.count()) 86 | self.assertEqual(2, Foo.objects_with_deleted.count()) 87 | 88 | foo2.delete(hard=False) 89 | self.assertEqual(1, Foo.objects.count()) 90 | self.assertEqual(1, Foo.objects_deleted.count()) 91 | self.assertEqual(2, Foo.objects_with_deleted.count()) 92 | 93 | foo2.delete(hard=True) 94 | self.assertEqual(1, Foo.objects.count()) 95 | self.assertEqual(0, Foo.objects_deleted.count()) 96 | self.assertEqual(1, Foo.objects_with_deleted.count()) 97 | 98 | def test_bulk_soft_delete(self): 99 | Foo().save() 100 | Foo().save() 101 | 102 | self.assertEqual(2, Foo.objects.count()) 103 | 104 | Foo.objects.delete() 105 | self.assertEqual(0, Foo.objects.count()) 106 | self.assertEqual(2, Foo.objects_deleted.count()) 107 | 108 | def test_bulk_soft_delete_2(self): 109 | Foo().save() 110 | Foo().save() 111 | 112 | self.assertEqual(2, Foo.objects.count()) 113 | 114 | Foo.objects.soft_delete() 115 | self.assertEqual(0, Foo.objects.count()) 116 | self.assertEqual(2, Foo.objects_deleted.count()) 117 | 118 | def test_bulk_hard_delete(self): 119 | Foo().save() 120 | Foo().save() 121 | 122 | self.assertEqual(2, Foo.objects.count()) 123 | 124 | Foo.objects.delete(hard=True) 125 | self.assertEqual(0, Foo.objects_with_deleted.count()) 126 | 127 | def test_bulk_hard_delete_2(self): 128 | Foo().save() 129 | Foo().save() 130 | 131 | self.assertEqual(2, Foo.objects.count()) 132 | 133 | Foo.objects.hard_delete() 134 | self.assertEqual(0, Foo.objects_with_deleted.count()) 135 | 136 | def test_bulk_restore(self): 137 | Foo().save() 138 | Foo().save() 139 | 140 | self.assertEqual(2, Foo.objects.count()) 141 | 142 | Foo.objects.delete() 143 | self.assertEqual(0, Foo.objects.count()) 144 | 145 | Foo.objects_deleted.restore() 146 | self.assertEqual(2, Foo.objects.count()) 147 | -------------------------------------------------------------------------------- /timestamps/drf/mixins.py: -------------------------------------------------------------------------------- 1 | import aspectlib 2 | 3 | from django.conf import settings 4 | from rest_framework import status, mixins 5 | from rest_framework.generics import GenericAPIView 6 | from rest_framework.mixins import ListModelMixin, RetrieveModelMixin 7 | from rest_framework.response import Response 8 | 9 | from timestamps.drf.utils import is_hard_delete_request 10 | 11 | 12 | class ListDeletedModelMixin: 13 | def list_deleted(self, request, *args, **kwargs): 14 | return ListModelMixin.list(self, request, *args, **kwargs) 15 | 16 | 17 | class ListWithDeletedModelMixin: 18 | def list_with_deleted(self, request, *args, **kwargs): 19 | return ListModelMixin.list(self, request, *args, **kwargs) 20 | 21 | 22 | class RetrieveDeletedModelMixin: 23 | def retrieve_deleted(self, request, *args, **kwargs): 24 | return RetrieveModelMixin.retrieve(self, request, *args, **kwargs) 25 | 26 | 27 | class RetrieveWithDeletedModelMixin: 28 | def retrieve_with_deleted(self, request, *args, **kwargs): 29 | return RetrieveModelMixin.retrieve(self, request, *args, **kwargs) 30 | 31 | 32 | class RestoreModelMixin: 33 | def restore(self, request, *args, **kwargs): 34 | instance = self.get_object() 35 | self.perform_restore(instance) 36 | 37 | return Response(self.get_serializer(instance=instance).data) 38 | 39 | def perform_restore(self, instance): 40 | return instance.restore() 41 | 42 | 43 | class BulkRestoreModelMixin: 44 | def bulk_restore(self, request, *args, **kwargs): 45 | queryset = self.filter_queryset(self.get_queryset()) 46 | count = self.perform_bulk_restore(queryset) 47 | 48 | if getattr(settings, 'TIMESTAMPS__BULK_RESPONSE_CONTENT', False): 49 | return Response(data={'count': count, }, status=status.HTTP_200_OK) 50 | 51 | return Response(status=status.HTTP_204_NO_CONTENT) 52 | 53 | def perform_bulk_restore(self, qs): 54 | return qs.restore() 55 | 56 | 57 | class DestroyModelMixin(mixins.DestroyModelMixin): 58 | def perform_destroy(self, instance): 59 | return instance.delete(hard=is_hard_delete_request(self)) 60 | 61 | 62 | class BulkDestroyModelMixin: 63 | def perform_bulk_destroy(self, qs): 64 | return qs.delete(hard=is_hard_delete_request(self)) 65 | 66 | def bulk_destroy(self, request, *args, **kwargs): 67 | queryset = self.filter_queryset(self.get_queryset()) 68 | 69 | count = self.perform_bulk_destroy(queryset) 70 | 71 | if not getattr(settings, 'TIMESTAMPS__BULK_RESPONSE_CONTENT', False): 72 | return Response(status=status.HTTP_204_NO_CONTENT) 73 | 74 | # a delete operation (hard delete) returns a tuple of: 75 | # - total rows deleted (count) 76 | # - total rows deleted per table (per_model) 77 | if isinstance(count, tuple): 78 | count, count_per_model = count 79 | return Response(data={'count': count, 'count_per_model': count_per_model, }, status=status.HTTP_200_OK) 80 | 81 | return Response(data={'count': count}, status=status.HTTP_200_OK) 82 | 83 | 84 | def __remove_clause_deleted_at(queryset): 85 | from timestamps.querysets import SoftDeleteQuerySet 86 | from django.db.models.lookups import IsNull 87 | 88 | if not isinstance(queryset, SoftDeleteQuerySet): 89 | return queryset 90 | 91 | queryset = queryset.all() # clone 92 | where = queryset.query.where 93 | 94 | for i, child in enumerate(where.children): 95 | if isinstance(child, IsNull) and child.lhs.field.name == 'deleted_at': 96 | where.children.pop(i) 97 | break 98 | 99 | return queryset 100 | 101 | 102 | # Using Aspect-Oriented Programming (AOP) 103 | # to change behavior of GenericAPIView.get_queryset(self). 104 | # Doing this way, there is no need to pollute CoreModelViewSet 105 | # and gives to developers the possibility 106 | # to use only a subset of Mixins of soft deleting technology, 107 | # without the need to use all the views mixins, extended in CoreModelViewSet. 108 | @aspectlib.Aspect 109 | def __get_queryset(*args, **kwargs): 110 | queryset = yield aspectlib.Proceed 111 | 112 | view = args[0] 113 | 114 | if not hasattr(view, 'action'): 115 | yield aspectlib.Return(queryset) 116 | 117 | mixin = { 118 | 'list_with_deleted': ListWithDeletedModelMixin, 119 | 'retrieve_with_deleted': RetrieveWithDeletedModelMixin, 120 | } 121 | 122 | if is_hard_delete_request(view): 123 | mixin['destroy'] = DestroyModelMixin 124 | mixin['bulk_destroy'] = BulkDestroyModelMixin 125 | 126 | mixin = mixin.get(view.action, None) 127 | 128 | if mixin and isinstance(view, mixin): 129 | queryset = __remove_clause_deleted_at(queryset) 130 | yield aspectlib.Return(queryset) 131 | 132 | mixin = { 133 | 'list_deleted': ListDeletedModelMixin, 134 | 'retrieve_deleted': RetrieveDeletedModelMixin, 135 | 'restore': RestoreModelMixin, 136 | 'bulk_restore': BulkRestoreModelMixin, 137 | }.get(view.action, None) 138 | 139 | if mixin and isinstance(view, mixin): 140 | queryset = __remove_clause_deleted_at(queryset) 141 | yield aspectlib.Return(queryset.only_deleted()) 142 | 143 | yield aspectlib.Return(queryset) 144 | 145 | 146 | aspectlib.weave(target=GenericAPIView.get_queryset, aspects=__get_queryset) 147 | -------------------------------------------------------------------------------- /tests/tests/drf.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import TestCase 3 | from rest_framework import status 4 | from tests.models import Foo 5 | 6 | 7 | class SoftDeleteModelViewSetTestCase(TestCase): 8 | fixtures = ['foos'] 9 | 10 | def setUp(self): 11 | setattr(settings, 'TIMESTAMPS__BULK_RESPONSE_CONTENT', True) 12 | setattr(settings, 'TIMESTAMPS__BULK_HARD_DELETE', False) 13 | 14 | def test_get_objects(self): 15 | response = self.client.get('/foos/') 16 | self.assertEqual(status.HTTP_200_OK, response.status_code) 17 | 18 | results = response.json() 19 | self.assertEqual(3, len(results)) 20 | 21 | def test_get_objects_deleted(self): 22 | response = self.client.get('/foos/deleted/') 23 | self.assertEqual(status.HTTP_200_OK, response.status_code) 24 | 25 | results = response.json() 26 | self.assertEqual(1, len(results)) 27 | 28 | def test_get_objects_with_deleted(self): 29 | response = self.client.get('/foos/with-deleted/') 30 | self.assertEqual(status.HTTP_200_OK, response.status_code) 31 | 32 | results = response.json() 33 | self.assertEqual(4, len(results)) 34 | 35 | def test_get_object_deleted(self): 36 | pk_active = '32a6fab2-9e0f-4d23-9a3b-c642470e629d' 37 | pk_deleted = '381b0e65-bc13-43de-b216-8673c18aa645' 38 | 39 | response = self.client.get('/foos/deleted/{}/'.format(pk_active)) 40 | self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code) 41 | 42 | response = self.client.get('/foos/deleted/{}/'.format(pk_deleted)) 43 | self.assertEqual(status.HTTP_200_OK, response.status_code) 44 | 45 | response_data = response.json() 46 | self.assertEqual(pk_deleted, response_data.get('id')) 47 | 48 | def test_get_object_with_deleted(self): 49 | pk_active = '32a6fab2-9e0f-4d23-9a3b-c642470e629d' 50 | pk_deleted = '381b0e65-bc13-43de-b216-8673c18aa645' 51 | 52 | for pk in [pk_active, pk_deleted]: 53 | response = self.client.get('/foos/with-deleted/{}/'.format(pk)) 54 | self.assertEqual(status.HTTP_200_OK, response.status_code) 55 | 56 | response_data = response.json() 57 | self.assertEqual(pk, response_data.get('id')) 58 | 59 | def test_delete_one_object(self): 60 | pk = '32a6fab2-9e0f-4d23-9a3b-c642470e629d' 61 | 62 | response = self.client.delete('/foos/{}/'.format(pk)) 63 | self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) 64 | 65 | def test_soft_delete_one_object(self): 66 | pk = '32a6fab2-9e0f-4d23-9a3b-c642470e629d' 67 | 68 | response = self.client.delete('/foos/{}/?permanent=0'.format(pk)) 69 | self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) 70 | 71 | def test_hard_delete_one_object(self): 72 | pk = '32a6fab2-9e0f-4d23-9a3b-c642470e629d' 73 | 74 | response = self.client.delete('/foos/{}/?permanent=1'.format(pk)) 75 | self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) 76 | 77 | def test_delete_one_object_with_permanent_invalid(self): 78 | pk = '32a6fab2-9e0f-4d23-9a3b-c642470e629d' 79 | 80 | for invalid_option in ['123', 'abc', 'a0s8', ' ']: 81 | response = self.client.delete('/foos/{}/?permanent={}'.format(pk, invalid_option)) 82 | self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) 83 | 84 | def test_delete_with_truthful_permanent_options(self): 85 | setattr(settings, 'TIMESTAMPS__BULK_HARD_DELETE', True) 86 | setattr(settings, 'TIMESTAMPS__BULK_RESPONSE_CONTENT', False) 87 | 88 | truthful_options = [ 89 | 't', 'T', 90 | 'y', 'Y', 'yes', 'Yes', 'YES', 91 | 'true', 'True', 'TRUE', 92 | 'on', 'On', 'ON', 93 | '1', 1, 94 | True 95 | ] 96 | 97 | for option in truthful_options: 98 | foo = Foo(name='test') 99 | foo.save() 100 | 101 | response = self.client.delete('/foos/{}/?permanent={}'.format(foo.pk, option)) 102 | self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code, 'invalid option: {}'.format(option)) 103 | 104 | def test_delete_with_falsy_permanent_options(self): 105 | pk = '32a6fab2-9e0f-4d23-9a3b-c642470e629d' 106 | 107 | falsely_options = [ 108 | 'f', 'F', 109 | 'n', 'N', 'no', 'No', 'NO', 110 | 'false', 'False', 'FALSE', 111 | 'off', 'Off', 'OFF', 112 | '0', 0, 113 | 'null', 114 | False 115 | ] 116 | 117 | for option in falsely_options: 118 | response = self.client.delete('/foos/{}/?permanent={}'.format(pk, option)) 119 | self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code, 'invalid option: {}'.format(option)) 120 | 121 | response = self.client.patch('/foos/{}/restore/'.format(pk)) 122 | self.assertEqual(status.HTTP_200_OK, response.status_code) 123 | 124 | def test_restore_one_object(self): 125 | pk = '381b0e65-bc13-43de-b216-8673c18aa645' 126 | 127 | response = self.client.patch('/foos/{}/restore/'.format(pk)) 128 | self.assertEqual(status.HTTP_200_OK, response.status_code) 129 | 130 | response_data = response.json() 131 | self.assertEqual(pk, response_data.get('id')) 132 | 133 | def test_delete_objects(self): 134 | setattr(settings, 'TIMESTAMPS__BULK_RESPONSE_CONTENT', True) 135 | 136 | response = self.client.delete('/foos/') 137 | self.assertEqual(status.HTTP_200_OK, response.status_code) 138 | 139 | response_data = response.json() 140 | self.assertEqual(3, response_data.get('count')) 141 | 142 | def test_restore_objects(self): 143 | setattr(settings, 'TIMESTAMPS__BULK_RESPONSE_CONTENT', True) 144 | 145 | self.client.delete('/foos/') 146 | 147 | response = self.client.patch('/foos/restore/') 148 | self.assertEqual(status.HTTP_200_OK, response.status_code) 149 | 150 | response_data = response.json() 151 | self.assertEqual(4, response_data.get('count')) 152 | 153 | 154 | class ViewsWithNoActionTestCase(TestCase): 155 | fixtures = ['foos', 'bars'] 156 | 157 | def test_get_object(self): 158 | uuid = "14e6c142-580e-4c32-815c-ee347b03b567" 159 | 160 | response = self.client.get(f'/bars/{uuid}/') 161 | self.assertEqual(status.HTTP_200_OK, response.status_code) 162 | 163 | result = response.json() 164 | self.assertEqual(uuid, result.get('id')) 165 | 166 | def test_get_object_deleted_object_returns_404(self): 167 | uuid = "6ee25ce6-4c53-4c7a-9283-078b11e8fe97" 168 | 169 | response = self.client.get(f'/bars/{uuid}/') 170 | self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code) 171 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python,django,visualstudiocode,sublimetext,pycharm+all 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,django,visualstudiocode,sublimetext,pycharm+all 4 | 5 | ### Django ### 6 | *.log 7 | *.pot 8 | *.pyc 9 | __pycache__/ 10 | local_settings.py 11 | db.sqlite3 12 | db.sqlite3-journal 13 | media 14 | 15 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 16 | # in your Git repository. Update and uncomment the following line accordingly. 17 | # /staticfiles/ 18 | 19 | ### Django.Python Stack ### 20 | # Byte-compiled / optimized / DLL files 21 | *.py[cod] 22 | *$py.class 23 | 24 | # C extensions 25 | *.so 26 | 27 | # Distribution / packaging 28 | .Python 29 | build/ 30 | develop-eggs/ 31 | dist/ 32 | downloads/ 33 | eggs/ 34 | .eggs/ 35 | parts/ 36 | sdist/ 37 | var/ 38 | wheels/ 39 | pip-wheel-metadata/ 40 | share/python-wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | *.py,cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | pytestdebug.log 70 | 71 | # Translations 72 | *.mo 73 | 74 | # Django stuff: 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | doc/_build/ 86 | 87 | # PyBuilder 88 | target/ 89 | 90 | # Jupyter Notebook 91 | .ipynb_checkpoints 92 | 93 | # IPython 94 | profile_default/ 95 | ipython_config.py 96 | 97 | # pyenv 98 | .python-version 99 | 100 | # pipenv 101 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 102 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 103 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 104 | # install all needed dependencies. 105 | #Pipfile.lock 106 | 107 | # poetry 108 | #poetry.lock 109 | 110 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 111 | __pypackages__/ 112 | 113 | # Celery stuff 114 | celerybeat-schedule 115 | celerybeat.pid 116 | 117 | # SageMath parsed files 118 | *.sage.py 119 | 120 | # Environments 121 | # .env 122 | .env/ 123 | .venv/ 124 | env/ 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | pythonenv* 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # operating system-related files 153 | *.DS_Store #file properties cache/storage on macOS 154 | Thumbs.db #thumbnail cache on Windows 155 | 156 | # profiling data 157 | .prof 158 | 159 | 160 | ### PyCharm+all ### 161 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 162 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 163 | 164 | # User-specific stuff 165 | .idea/**/workspace.xml 166 | .idea/**/tasks.xml 167 | .idea/**/usage.statistics.xml 168 | .idea/**/dictionaries 169 | .idea/**/shelf 170 | 171 | # Generated files 172 | .idea/**/contentModel.xml 173 | 174 | # Sensitive or high-churn files 175 | .idea/**/dataSources/ 176 | .idea/**/dataSources.ids 177 | .idea/**/dataSources.local.xml 178 | .idea/**/sqlDataSources.xml 179 | .idea/**/dynamic.xml 180 | .idea/**/uiDesigner.xml 181 | .idea/**/dbnavigator.xml 182 | 183 | # Gradle 184 | .idea/**/gradle.xml 185 | .idea/**/libraries 186 | 187 | # Gradle and Maven with auto-import 188 | # When using Gradle or Maven with auto-import, you should exclude module files, 189 | # since they will be recreated, and may cause churn. Uncomment if using 190 | # auto-import. 191 | # .idea/artifacts 192 | # .idea/compiler.xml 193 | # .idea/jarRepositories.xml 194 | # .idea/modules.xml 195 | # .idea/*.iml 196 | # .idea/modules 197 | # *.iml 198 | # *.ipr 199 | 200 | # CMake 201 | cmake-build-*/ 202 | 203 | # Mongo Explorer plugin 204 | .idea/**/mongoSettings.xml 205 | 206 | # File-based project format 207 | *.iws 208 | 209 | # IntelliJ 210 | out/ 211 | 212 | # mpeltonen/sbt-idea plugin 213 | .idea_modules/ 214 | 215 | # JIRA plugin 216 | atlassian-ide-plugin.xml 217 | 218 | # Cursive Clojure plugin 219 | .idea/replstate.xml 220 | 221 | # Crashlytics plugin (for Android Studio and IntelliJ) 222 | com_crashlytics_export_strings.xml 223 | crashlytics.properties 224 | crashlytics-build.properties 225 | fabric.properties 226 | 227 | # Editor-based Rest Client 228 | .idea/httpRequests 229 | 230 | # Android studio 3.1+ serialized cache file 231 | .idea/caches/build_file_checksums.ser 232 | 233 | ### PyCharm+all Patch ### 234 | # Ignores the whole .idea folder and all .iml files 235 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 236 | 237 | .idea/ 238 | 239 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 240 | 241 | *.iml 242 | modules.xml 243 | .idea/misc.xml 244 | *.ipr 245 | 246 | # Sonarlint plugin 247 | .idea/sonarlint 248 | 249 | ### Python ### 250 | # Byte-compiled / optimized / DLL files 251 | 252 | # C extensions 253 | 254 | # Distribution / packaging 255 | 256 | # PyInstaller 257 | # Usually these files are written by a python script from a template 258 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 259 | 260 | # Installer logs 261 | 262 | # Unit test / coverage reports 263 | 264 | # Translations 265 | 266 | # Django stuff: 267 | 268 | # Flask stuff: 269 | 270 | # Scrapy stuff: 271 | 272 | # Sphinx documentation 273 | 274 | # PyBuilder 275 | 276 | # Jupyter Notebook 277 | 278 | # IPython 279 | 280 | # pyenv 281 | 282 | # pipenv 283 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 284 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 285 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 286 | # install all needed dependencies. 287 | 288 | # poetry 289 | 290 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 291 | 292 | # Celery stuff 293 | 294 | # SageMath parsed files 295 | 296 | # Environments 297 | # .env 298 | 299 | # Spyder project settings 300 | 301 | # Rope project settings 302 | 303 | # mkdocs documentation 304 | 305 | # mypy 306 | 307 | # Pyre type checker 308 | 309 | # pytype static type analyzer 310 | 311 | # operating system-related files 312 | 313 | # profiling data 314 | 315 | 316 | ### SublimeText ### 317 | # Cache files for Sublime Text 318 | *.tmlanguage.cache 319 | *.tmPreferences.cache 320 | *.stTheme.cache 321 | 322 | # Workspace files are user-specific 323 | *.sublime-workspace 324 | 325 | # Project files should be checked into the repository, unless a significant 326 | # proportion of contributors will probably not be using Sublime Text 327 | # *.sublime-project 328 | 329 | # SFTP configuration file 330 | sftp-config.json 331 | 332 | # Package control specific files 333 | Package Control.last-run 334 | Package Control.ca-list 335 | Package Control.ca-bundle 336 | Package Control.system-ca-bundle 337 | Package Control.cache/ 338 | Package Control.ca-certs/ 339 | Package Control.merged-ca-bundle 340 | Package Control.user-ca-bundle 341 | oscrypto-ca-bundle.crt 342 | bh_unicode_properties.cache 343 | 344 | # Sublime-github package stores a github token in this file 345 | # https://packagecontrol.io/packages/sublime-github 346 | GitHub.sublime-settings 347 | 348 | ### VisualStudioCode ### 349 | .vscode/* 350 | !.vscode/tasks.json 351 | !.vscode/launch.json 352 | *.code-workspace 353 | 354 | ### VisualStudioCode Patch ### 355 | # Ignore all local history of files 356 | .history 357 | .ionide 358 | 359 | # End of https://www.toptal.com/developers/gitignore/api/python,django,visualstudiocode,sublimetext,pycharm+all -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Timestamps 2 | 3 | Timestamps and Soft Delete Patterns in Django Models. 4 | 5 | ## ✅ Summary 6 | 7 | - Add timestamps and soft delete to any model with a single line of code. 8 | - Manage deleted objects effortlessly with built-in managers and custom querysets. 9 | - Seamlessly integrate into existing models without breaking changes or refactoring your app/project 10 | - ⭐ no need to modify the default objects manager or queryset ⭐ 11 | - Hook into lifecycle events with signals. 12 | - Get full CRUD support in DRF, including restore endpoints. 13 | - Configure safety features such as bulk hard delete and bulk responses. 14 | 15 | ## 🚀 Installation 16 | 17 | ### Without DRF 18 | 19 | ````bash 20 | $ pip install django-timestampable 21 | ```` 22 | 23 | ### With DRF support 24 | 25 | To install django-timestampable with [Django Rest Framework](https://www.django-rest-framework.org/) included: 26 | 27 | ````bash 28 | $ pip install "django-timestampable[drf]" 29 | ```` 30 | 31 | *You can use the first option if you have Django Rest Framework already installed.* 32 | 33 |   34 | 35 | ## ⚙️ Configuration 36 | 37 | Add `timestamps` to your `INSTALLED_APPS` in your Django settings: 38 | 39 | ```python 40 | INSTALLED_APPS = [ 41 | # ... 42 | 'timestamps', 43 | ] 44 | ``` 45 | 46 | ### Or, if you installed with [Django Rest Framework](https://www.django-rest-framework.org/): 47 | 48 | ```python 49 | INSTALLED_APPS = [ 50 | # ... 51 | 'rest_framework', 52 | 'timestamps', 53 | ] 54 | ``` 55 | 56 | ## 🛠 Usage 57 | 58 | a) For Timestamps 59 | 60 | ```python 61 | from timestamps.models import models, Timestampable 62 | 63 | class YourModel(Timestampable): 64 | # your fields here ... 65 | 66 | ``` 67 | 68 | b) For Soft Deletes 69 | 70 | ```python 71 | from timestamps.models import models, SoftDeletes 72 | 73 | class YourModel(SoftDeletes): 74 | # your fields here ... 75 | 76 | ``` 77 | 78 | c) Combine both Timestamps and Soft Deletes 79 | 80 | ```python 81 | # to this: 82 | from timestamps.models import models, Model # shortcut including both 83 | 84 | class YourModel(Model): 85 | # your fields here ... 86 | 87 | ``` 88 | 89 | **Note**: Always import Model from timestamps.models explicitly. models.Model from Django is untouched. 90 | 91 | ## 🧹 Soft Deleting 92 | 93 | ### Query managers 94 | 95 | When you use SoftDeletes or Model, you have 3 query managers available: 96 | 97 | - Active objects only (without soft deleted objects): 98 | 99 | ```queryset = YourModel.objects``` 100 | 101 | - Deleted objects only: 102 | 103 | ```queryset = YourModel.objects_deleted``` 104 | 105 | - All objects (active + deleted): 106 | 107 | ```queryset = YourModel.objects_with_deleted``` 108 | 109 | ### Operations 110 | 111 | #### To soft delete an instance 112 | 113 | ```python 114 | some_model = MyModel.objects.first() 115 | some_model.delete() # or some_model.delete(hard=False) 116 | ``` 117 | 118 | #### To restore an instance 119 | 120 | ```python 121 | some_model = MyModel.objects_deleted.first() 122 | some_model.restore() 123 | ``` 124 | 125 | #### To hard delete an instance 126 | 127 | ```python 128 | some_model = MyModel.objects.first() 129 | some_model.delete(hard=True) 130 | ``` 131 | 132 | #### To bulk soft delete a queryset 133 | 134 | ```python 135 | qs = MyModel.objects # you can also apply filters to bulk delete a subset: qs = MyModel.objects.filter(...) 136 | qs.delete() # or qs.delete(hard=False) 137 | ``` 138 | 139 | #### To bulk hard delete a queryset 140 | 141 | ```python 142 | qs = MyModel.objects # ... bulk hard delete a subset: qs = MyModel.objects.filter(...) 143 | qs.delete(hard=True) 144 | ``` 145 | 146 | #### To bulk restore a queryset 147 | 148 | ```python 149 | qs = MyModel.objects_deleted # ... bulk restore a subset: qs = MyModel.objects_deleted.filter(...) 150 | qs.restore() # or qs.delete(hard=False) 151 | ``` 152 | 153 | ## 📡 Signals 154 | 155 | Four signals are available: 156 | 157 | - pre_soft_delete 158 | - post_soft_delete 159 | - pre_restore 160 | - post_restore 161 | 162 | To use them, just import the signals and register listeners for them. Eg: 163 | 164 | ### Pre Soft Delete 165 | 166 | ```python3 167 | from timestamps.signals import pre_soft_delete 168 | from django.dispatch import receiver 169 | 170 | @receiver(pre_soft_delete) 171 | def on_pre_soft_delete(sender, instance, **kwargs): 172 | print(f"Model {sender} with id {instance.pk} will be deleted!") 173 | ``` 174 | 175 | ### Post Soft Delete 176 | 177 | ```python3 178 | from timestamps.signals import post_soft_delete 179 | from django.dispatch import receiver 180 | 181 | @receiver(post_soft_delete) 182 | def on_post_soft_delete(sender, instance, **kwargs): 183 | print(f"Model {sender} with id {instance.pk} was deleted at {instance.deleted_at}!") 184 | ``` 185 | 186 | ### Pre Restore 187 | 188 | ```python3 189 | from timestamps.signals import pre_restore 190 | from django.dispatch import receiver 191 | 192 | @receiver(pre_restore) 193 | def on_pre_restore(sender, instance, **kwargs): 194 | print(f"Model {sender} with id {instance.pk} deleted at {instance.deleted_at} will be restored!") 195 | ``` 196 | 197 | ### Post Restore 198 | 199 | ```python3 200 | from timestamps.signals import post_restore 201 | from django.dispatch import receiver 202 | 203 | @receiver(post_restore) 204 | def on_post_restore(sender, instance, **kwargs): 205 | print(f"Model {sender} with id {instance.pk} restored!") 206 | ``` 207 | 208 | ## 🌐 Using with DRF 209 | 210 | You can use the SoftDeleteModelViewSet along with DefaultRouter present in this package 211 | and you will have access to a complete CRUD on soft deleted objects as well. 212 | This 2 classes allows you to expose: 213 | 214 | Consider a Dummy Model that inherits from SoftDelete. 215 | 216 | You can have all routes for CRUD operations on this model: 217 | 218 | | VERB | URL PATH | DESCRIPTION | 219 | | ---- | -------- | ----------- | 220 | | GET | /dummy/ | gets all the objects, without the deleted ones | 221 | | POST | /dummy/ | creates a new object | 222 | | DELETE | /dummy/[?permanent=\] | deletes all objects (or a filtered subject). allows hard-delete. Default: soft-delete | 223 | | GET | /dummy/\/ | gets a non-deleted object (by primary key) | 224 | | POST | /dummy/\/ | updates an object (by primary key) | 225 | | PATCH | /dummy/\/ | partial updates an object (by primary key) | 226 | | DELETE | /dummy/\/[?permanent=\] | deletes a non-deleted object (by primary key) | 227 | | PATCH | /dummy/restore/ | restore all objects (or a filtered subject) | 228 | | PATCH | /dummy/\/restore/ | restores a soft-deleted object (by primary key) | 229 | | GET | /dummy/deleted/ | gets all deleted objects | 230 | | GET | /dummy/deleted/\/ | gets a deleted object (by primary key) | 231 | | GET | /dummy/with-deleted/ | get all objects, deleted included | 232 | | GET | /dummy/with-deleted/\/ | get an object (by primary key) | 233 | 234 |   235 | 236 | The query parameter "permanent" it's case-sensitive and can also be one of the values: 237 | 238 | ```python 239 | truthful_options = [ 240 | 't', 'T', 241 | 'y', 'Y', 'yes', 'Yes', 'YES', 242 | 'true', 'True', 'TRUE', 243 | 'on', 'On', 'ON', 244 | '1', 1, 245 | True 246 | ] 247 | ``` 248 | 249 | ```python 250 | falsely_options = [ 251 | 'f', 'F', 252 | 'n', 'N', 'no', 'No', 'NO', 253 | 'false', 'False', 'FALSE', 254 | 'off', 'Off', 'OFF', 255 | '0', 0, 256 | 'null', 257 | False 258 | ] 259 | ``` 260 | 261 | ### How to expose all CRUD operations 262 | 263 | ```python 264 | # dummy/views.py 265 | from timestamps.drf import viewsets # instead of: from rest_framework import viewsets 266 | from .models import Dummy 267 | from .serializers import DummySerializer 268 | 269 | 270 | class DummyModelViewSet(viewsets.ModelViewSet): 271 | queryset = Dummy.objects.all() 272 | serializer_class = DummySerializer 273 | 274 | ``` 275 | 276 | ````python 277 | # dummy/urls.py 278 | from timestamps.drf import routers # instead of: from rest_framework import routers 279 | from .views import DummyModelViewSet 280 | 281 | 282 | router = routers.DefaultRouter() 283 | router.register(r'dummy', DummyModelViewSet) 284 | 285 | 286 | urlpatterns = router.urls 287 | 288 | ```` 289 | 290 | ## ⚠️ Notes & Settings 291 | 292 | ### Note A - About Bulk Hard Delete 293 | 294 | For security reasons, by default, if you pass to the query parameter "?permanent=true" on a bulk destroy, 295 | the view will not let you hard-delete, raising a PermissionDenied. 296 | If you want to enable it on your project, just add to the project settings: 297 | 298 | ```python 299 | TIMESTAMPS__BULK_HARD_DELETE = True 300 | ``` 301 | 302 | It's here to prevent users of "forgetting" that the routes also expose bulk hard-delete by default. 303 | In production, you can set this flag to True and manage hard-deleting using DRF permissions. 304 | 305 | *Hard-deleting one object at time is allowed by default.* 306 | 307 |   308 | 309 | ### Note B - About Bulk Response 310 | 311 | Bulk actions of restoring and deleting returns no content (status code 204) by default. 312 | If you want to return a response with the number of deleted/restored objects, just add this setting: 313 | 314 | ```python 315 | TIMESTAMPS__BULK_RESPONSE_CONTENT = True 316 | ``` 317 | 318 | Example of returned response: ```{"count": 3 }``` 319 | 320 |   321 | 322 | ### Note C - Selective Routes 323 | 324 | If you don't want to expose all the crud operations, be free to register as: 325 | 326 | ```python 327 | router.register(r'dummy', DummyModelViewSet.as_view({'get': 'list_with_deleted'})) # e.g. 328 | ``` 329 | 330 | Or use DRF mixins instead: 331 | 332 | ````python 333 | from rest_framework import generic 334 | from timestamps.drf.mixins import ListDeletedModelMixin 335 | from .models import Dummy 336 | from .serializers import DummySerializer 337 | 338 | class MyView(ListDeletedModelMixin, generic.GenericAPIView): 339 | queryset = Dummy.objects.all() 340 | serializer_class = DummySerializer 341 | 342 | def list_deleted(self, request, *args, **kwargs): 343 | # optional. your code goes here... 344 | 345 | ```` 346 | 347 | Internally, the ListDeletedModelMixin just calls the method ListModelMixin.list(self, request, *args, **kwargs). 348 | The method of determining if the queryset must get all objects, only the deleted or all with deleted is done using AOP, 349 | which means that the method GenericAPIView.get_queryset() is advised at runtime to map the current action 350 | to the correct queryset the view needs. 351 | 352 | If you don't inherit from generic.GenericAPIView, you must be aware that, for this type of scenarios, 353 | you need to override the method get_queryset() to return the objects that matches your needs. 354 | 355 |   356 | 357 | --- 358 | 359 |   360 | 361 | Thanks for using `django-timestampable`! 🎉 362 | --------------------------------------------------------------------------------