Yet another approach to provide soft (logical) delete or masking (thrashing) django models instead of deleting them physically from db.
Install using pip:
pip install django-permanentOr install from source:
git clone https://github.com/meteozond/django-permanent.git
cd django-permanent
python setup.py installRequirements:
- Python 3.10+
- Django 4.2+
Setup:
Add django_permanent to your INSTALLED_APPS:
INSTALLED_APPS = [
...
'django_permanent',
]This enables Django System Checks that warn about problematic configurations.
To create a non-deletable model just inherit it from PermanentModel:
class MyModel(PermanentModel):
passIt automatically changes delete behaviour to hide objects instead of deleting them:
>>> a = MyModel.objects.create(pk=1)
>>> b = MyModel.objects.create(pk=2)
>>> MyModel.objects.count()
2
>>> a.delete()
>>> MyModel.objects.count()
1To recover a deleted object just call its restore method:
>>> a.restore()
>>> MyModel.objects.count()
2Use the force kwarg to enforce physical deletion:
>>> a.delete(force=True) # Will act as the default django delete
>>> MyModel._base_manager.count()
0If you want create() to restore deleted objects instead of raising an integrity error on unique constraints, use the restore_on_create option:
class Article(PermanentModel):
title = models.CharField(max_length=100, unique=True)
class Permanent:
restore_on_create = TrueHow it works:
When restore_on_create = True, calling Model.objects.create(**kwargs) will:
- First try to find a matching object (including soft-deleted ones)
- If found and deleted: restore it and update with new kwargs
- If found and not deleted: return the existing object
- If not found: create a new object
Example:
>>> article = Article.objects.create(title="Django Tips")
>>> article.delete() # Soft delete
>>> Article.objects.count()
0
# Without restore_on_create: would raise IntegrityError
# With restore_on_create: restores the deleted article
>>> article2 = Article.objects.create(title="Django Tips")
>>> article2.pk == article.pk # Same object!
True
>>> Article.objects.count()
1Note: This feature is most useful for models with unique constraints where you want to "resurrect" deleted objects rather than creating duplicates.
It changes the default model manager to ignore deleted objects, adding a deleted_objects manager to see them instead:
>>> MyModel.objects.count()
2
>>> a.delete()
>>> MyModel.objects.count()
1
>>> MyModel.deleted_objects.count()
1
>>> MyModel.all_objects.count()
2
>>> MyModel._base_manager.count()
2By default, accessing a foreign key to a deleted object will raise DoesNotExist. Use show_all_context() to access deleted related objects:
from django_permanent.related import show_all_context
# Create models with relationship
parent = ParentModel.objects.create(name="parent")
child = ChildModel.objects.create(parent=parent)
# Soft delete parent
parent.delete()
# Without show_all_context: raises DoesNotExist
child.parent # Raises ParentModel.DoesNotExist
# With show_all_context: can access deleted parent
with show_all_context():
print(child.parent.name) # Works! Returns "parent"Note: This is useful when you need to access relationships to soft-deleted objects, for example in admin interfaces or audit logs.
The QuerySet.delete method will act as the default django delete, with one exception - objects of models subclassing PermanentModel will be marked as deleted; the rest will be deleted physically:
>>> MyModel.objects.all().delete()You can still force django query set physical deletion:
>>> MyModel.objects.all().delete(force=True)-
Inherit your query set from
PermanentQuerySet:class ServerFileQuerySet(PermanentQuerySet) pass
-
Wrap
PermanentQuerySetorDeletedQuerySetin you model manager declaration:class MyModel(PermanentModel) objects = MultiPassThroughManager(ServerFileQuerySet, NonDeletedQuerySet) deleted_objects = MultiPassThroughManager(ServerFileQuerySet, DeletedQuerySet) all_objects = MultiPassThroughManager(ServerFileQuerySet, PermanentQuerySet)
- Check for existence of the object.
- Restore it if it was deleted.
- Create a new one, if it was never created.
The default field named is 'removed', but you can override it with the PERMANENT_FIELD variable in settings.py:
PERMANENT_FIELD = 'deleted'- Django 4.2+
- Python 3.10, 3.11, 3.12+
The project uses GitHub Actions for continuous integration.
Run tests locally using act (GitHub Actions locally):
# Install act (macOS)
brew install act
# Run all tests in parallel
act
# Run specific Python version
act --matrix python-version:3.11
# Run specific Python/Django combination
act --matrix python-version:3.11 --matrix django-version:"Django>=4.2,<5.0"Run tests directly:
# Install dependencies
pip install "Django>=4.2,<5.0" coverage flake8
# Run linter
flake8 django_permanent
# Run tests with coverage
coverage run runtests.py
coverage report