New Upstream Release - django-cleanup

Ready changes

Summary

Merged new upstream version: 7.0.0 (was: 6.0.0).

Resulting package

Built on 2023-05-26T21:35 (took 4m31s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases python3-django-cleanup

Lintian Result

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd64ac7..59b6de9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+## [7.0.0] - 2023-02-11
+### Added
+- Run tests for django 4.1.
+- Run tests on python 3.11 with django 4.1.
+- Select mode, with the ability to only cleanup selected models using a `select` decorator. Resolves issue [#75] for [@daviddavis](https://github.com/daviddavis).
+- Documentation on the known limitations of referencing a file by multiple model instances. Resolves issue [#98] for [@Grosskopf](https://github.com/Grosskopf)
+
+## Changed
+- Pass more data to the cleanup_pre_delete and cleanup_post_delete signals. Resolves issue [#96] for [@NadavK](https://github.com/NadavK).
+
+### Removed
+- Dropped support for django 2.2 and python 3.5.
+
+## [6.0.0] - 2022-01-24
+### Added
+- Update to run tests for python 3.10. PR [#88] from [@johnthagen](https://github.com/johnthagen).
+- GitHub Actions. Resolves issue [#89] for [@johnthagen](https://github.com/johnthagen).
+
+### Changed
+- Fix default_app_config deprecation. PR [#86] from [@nikolaik](https://github.com/nikolaik).
+
+### Removed
+- Dropped support for django 3.0 and 3.1.
+- Travis configuration.
 
 ## [5.2.0] - 2021-04-18
 ### Added
@@ -58,7 +82,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [0.1.4] - 2012-08-16
 ## [0.1.0] - 2012-08-14
 
-[Unreleased]: https://github.com/un1t/django-cleanup/compare/5.1.0...HEAD
+[Unreleased]: https://github.com/un1t/django-cleanup/compare/7.0.0...HEAD
+[7.0.0]: https://github.com/un1t/django-cleanup/compare/6.0.0...7.0.0
+[6.0.0]: https://github.com/un1t/django-cleanup/compare/5.2.0...6.0.0
 [5.2.0]: https://github.com/un1t/django-cleanup/compare/5.1.0...5.2.0
 [5.1.0]: https://github.com/un1t/django-cleanup/compare/5.0.0...5.1.0
 [5.0.0]: https://github.com/un1t/django-cleanup/compare/4.0.1...5.0.0
@@ -92,8 +118,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 [0.1.4]: https://github.com/un1t/django-cleanup/compare/0.1.0...0.1.4
 [0.1.0]: https://github.com/un1t/django-cleanup/releases/tag/0.1.0
 
+[#98]: https://github.com/un1t/django-cleanup/issues/98
+[#96]: https://github.com/un1t/django-cleanup/issues/96
+[#89]: https://github.com/un1t/django-cleanup/issues/89
+[#88]: https://github.com/un1t/django-cleanup/pull/88
+[#86]: https://github.com/un1t/django-cleanup/pull/86
 [#81]: https://github.com/un1t/django-cleanup/pull/81
 [#80]: https://github.com/un1t/django-cleanup/pull/80
 [#76]: https://github.com/un1t/django-cleanup/pull/76
+[#75]: https://github.com/un1t/django-cleanup/issues/75
 [#74]: https://github.com/un1t/django-cleanup/pull/74
 [#73]: https://github.com/un1t/django-cleanup/issues/73
diff --git a/PKG-INFO b/PKG-INFO
index 7f158d8..98bf080 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,225 +1,258 @@
 Metadata-Version: 2.1
 Name: django-cleanup
-Version: 5.2.0
+Version: 7.0.0
 Summary: Deletes old files.
 Home-page: https://github.com/un1t/django-cleanup
+Download-URL: https://github.com/un1t/django-cleanup/tarball/master
 Author: Ilya Shalyapin
 Author-email: ishalyapin@gmail.com
 License: MIT License
-Download-URL: https://github.com/un1t/django-cleanup/tarball/master
-Description: Django Cleanup
-        **************
-        |Version| |Status| |License|
-        
-        Features
-        ========
-        The django-cleanup app automatically deletes files for :code:`FileField`, :code:`ImageField` and
-        subclasses. When a :code:`FileField`'s value is changed and the model is saved, the old file is
-        deleted. When a model that has a :code:`FileField` is deleted, the file is also deleted. A file that
-        is set as the :code:`FileField`'s default value will not be deleted.
-        
-        Compatibility
-        -------------
-        - Django 2.2, 3.0, 3.1, 3.2 (`See Django Supported Versions <https://www.djangoproject.com/download/#supported-versions>`_)
-        - Python 3.5+
-        - Compatible with `sorl-thumbnail <https://github.com/jazzband/sorl-thumbnail>`_
-        - Compatible with `easy-thumbnail <https://github.com/SmileyChris/easy-thumbnails>`_
-        
-        How does it work?
-        =================
-        In order to track changes of a :code:`FileField` and facilitate file deletions, django-cleanup
-        connects :code:`post_init`, :code:`pre_save`, :code:`post_save` and :code:`post_delete` signals to
-        signal handlers for each :code:`INSTALLED_APPS` model that has a :code:`FileField`. In order to tell
-        whether or not a :code:`FileField`'s value has changed a local cache of original values is kept on
-        the model instance. If a condition is detected that should result in a file deletion, a function to
-        delete the file is setup and inserted into the commit phase of the current transaction.
-        
-        **Warning! If you are using a database that does not support transactions you may lose files if a
-        transaction will rollback at the right instance. This outcome is mitigated by our use of
-        post_save and post_delete signals, and by following the recommended configuration below. This
-        outcome will still occur if there are signals registered after app initialization and there are
-        exceptions when those signals are handled. In this case, the old file will be lost and the new file
-        will not be referenced in a model, though the new file will likely still exist on disk. If you are
-        concerned about this behavior you will need another solution for old file deletion in your project.**
-        
-        Installation
-        ============
-        ::
-        
-            pip install django-cleanup
-        
-        
-        Configuration
-        =============
-        Add ``django_cleanup`` to the bottom of ``INSTALLED_APPS`` in ``settings.py``
-        
-        .. code-block:: py
-        
-            INSTALLED_APPS = (
-                ...,
-                'django_cleanup.apps.CleanupConfig',
-            )
-        
-        That is all, no other configuration is necessary.
-        
-        Note: Order of ``INSTALLED_APPS`` is important. To ensure that exceptions inside other apps' signal
-        handlers do not affect the integrity of file deletions within transactions, ``django_cleanup``
-        should be placed last in ``INSTALLED_APPS``.
-        
-        Troubleshooting
-        ===============
-        If you notice that ``django-cleanup`` is not removing files when expected, check that your models
-        are being properly
-        `loaded <https://docs.djangoproject.com/en/stable/ref/applications/#how-applications-are-loaded>`_:
-        
-            You must define or import all models in your application's models.py or models/__init__.py.
-            Otherwise, the application registry may not be fully populated at this point, which could cause
-            the ORM to malfunction.
-        
-        If your models are not loaded, ``django-cleanup`` will not be able to discover their
-        ``FileField``'s.
-        
-        You can check if your ``Model`` is loaded by using
-        
-        .. code-block:: py
-        
-            from django.apps import apps
-            apps.get_models()
-        
-        Advanced
-        ========
-        This section contains additional functionality that can be used to interact with django-cleanup for
-        special cases.
-        
-        Signals
-        -------
-        To facilitate interactions with other django apps django-cleanup sends the following signals which
-        can be imported from :code:`django_cleanup.signals`:
-        
-        - :code:`cleanup_pre_delete`: just before a file is deleted. Passes a :code:`file` keyword argument.
-        - :code:`cleanup_post_delete`: just after a file is deleted. Passes a :code:`file` keyword argument.
-        
-        Signals example for sorl.thumbnail:
-        
-        .. code-block:: py
-        
-            from django_cleanup.signals import cleanup_pre_delete
-            from sorl.thumbnail import delete
-        
-            def sorl_delete(**kwargs):
-                delete(kwargs['file'])
-        
-            cleanup_pre_delete.connect(sorl_delete)
-        
-        Refresh the cache
-        -----------------
-        There have been rare cases where the cache would need to be refreshed. To do so the
-        :code:`django_cleanup.cleanup.refresh` method can be used:
-        
-        .. code-block:: py
-        
-            from django_cleanup import cleanup
-        
-            cleanup.refresh(model_instance)
-        
-        Ignore cleanup for a specific model
-        -----------------------------------
-        Ignore a model and do not perform cleanup when the model is deleted or its files change.
-        
-        .. code-block:: py
-        
-            from django_cleanup import cleanup
-        
-            @cleanup.ignore
-            class MyModel(models.Model):
-                image = models.FileField()
-        
-        How to run tests
-        ================
-        Install, setup and use pyenv_ to install all the required versions of cPython
-        (see the `tox.ini <https://github.com/un1t/django-cleanup/blob/master/tox.ini>`_).
-        
-        Setup pyenv_ to have all versions of python activated within your local django-cleanup repository.
-        Ensuring that the python 3.8 that was installed is first priority.
-        
-        Install tox_ on python 3.8 and run the :code:`tox` command from your local django-cleanup
-        repository.
-        
-        How to write tests
-        ==================
-        This app requires the use of django.test.TransactionTestCase_ when writing tests.
-        
-        For details on why this is required see `here
-        <https://docs.djangoproject.com/en/stable/topics/db/transactions/#use-in-tests>`_:
-        
-            Django's :code:`TestCase` class wraps each test in a transaction and rolls back that transaction
-            after each test, in order to provide test isolation. This means that no transaction is ever
-            actually committed, thus your :code:`on_commit()` callbacks will never be run. If you need to
-            test the results of an :code:`on_commit()` callback, use a :code:`TransactionTestCase` instead.
-        
-        License
-        =======
-        django-cleanup is free software under terms of the:
-        
-        MIT License
-        
-        Copyright (C) 2012 by Ilya Shalyapin, ishalyapin@gmail.com
-        
-        Permission is hereby granted, free of charge, to any person obtaining a copy
-        of this software and associated documentation files (the "Software"), to deal
-        in the Software without restriction, including without limitation the rights
-        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-        copies of the Software, and to permit persons to whom the Software is
-        furnished to do so, subject to the following conditions:
-        
-        The above copyright notice and this permission notice shall be included in all
-        copies or substantial portions of the Software.
-        
-        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-        SOFTWARE.
-        
-        
-        .. _django.test.TransactionTestCase: https://docs.djangoproject.com/en/stable/topics/testing/tools/#django.test.TransactionTestCase
-        .. _pyenv: https://github.com/pyenv/pyenv
-        .. _tox: https://tox.readthedocs.io/en/latest/
-        
-        .. |Version| image:: https://img.shields.io/pypi/v/django-cleanup.svg
-           :target: https://pypi.python.org/pypi/django-cleanup/
-           :alt: PyPI Package
-        .. |Status| image:: https://travis-ci.org/un1t/django-cleanup.svg?branch=master
-           :target: https://travis-ci.org/un1t/django-cleanup
-           :alt: Build Status
-        .. |License| image:: https://img.shields.io/badge/license-MIT-maroon
-           :target: https://github.com/un1t/django-cleanup/blob/master/LICENSE
-           :alt: MIT License
-        
 Keywords: django
-Platform: UNKNOWN
 Classifier: Development Status :: 5 - Production/Stable
 Classifier: Environment :: Web Environment
 Classifier: Framework :: Django
-Classifier: Framework :: Django :: 2.2
-Classifier: Framework :: Django :: 3.0
-Classifier: Framework :: Django :: 3.1
 Classifier: Framework :: Django :: 3.2
+Classifier: Framework :: Django :: 4.0
+Classifier: Framework :: Django :: 4.1
 Classifier: Intended Audience :: Developers
 Classifier: License :: OSI Approved :: MIT License
 Classifier: Operating System :: OS Independent
 Classifier: Programming Language :: Python
 Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.5
 Classifier: Programming Language :: Python :: 3.6
 Classifier: Programming Language :: Python :: 3.7
 Classifier: Programming Language :: Python :: 3.8
 Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
 Classifier: Programming Language :: Python :: Implementation :: CPython
 Classifier: Programming Language :: Python :: Implementation :: PyPy
 Classifier: Topic :: Utilities
-Requires: python (>=3.5)
-Requires: django (>=2.2)
+Requires: python (>=3.6)
+Requires: django (>=3.2)
 Description-Content-Type: text/x-rst
+License-File: LICENSE
+
+Django Cleanup
+**************
+|Version| |Status| |License|
+
+Features
+========
+The django-cleanup app automatically deletes files for :code:`FileField`, :code:`ImageField` and
+subclasses. When a :code:`FileField`'s value is changed and the model is saved, the old file is
+deleted. When a model that has a :code:`FileField` is deleted, the file is also deleted. A file that
+is set as the :code:`FileField`'s default value will not be deleted.
+
+Compatibility
+-------------
+- Django 3.2, 4.0, 4.1 (`See Django Supported Versions <https://www.djangoproject.com/download/#supported-versions>`_)
+- Python 3.6+
+- Compatible with `sorl-thumbnail <https://github.com/jazzband/sorl-thumbnail>`_
+- Compatible with `easy-thumbnail <https://github.com/SmileyChris/easy-thumbnails>`_
+
+How does it work?
+=================
+In order to track changes of a :code:`FileField` and facilitate file deletions, django-cleanup
+connects :code:`post_init`, :code:`pre_save`, :code:`post_save` and :code:`post_delete` signals to
+signal handlers for each :code:`INSTALLED_APPS` model that has a :code:`FileField`. In order to tell
+whether or not a :code:`FileField`'s value has changed a local cache of original values is kept on
+the model instance. If a condition is detected that should result in a file deletion, a function to
+delete the file is setup and inserted into the commit phase of the current transaction.
+
+**Warning! Please be aware of the known limitations documented below!**
+
+Installation
+============
+::
+
+    pip install django-cleanup
+
+
+Configuration
+=============
+Add ``django_cleanup`` to the bottom of ``INSTALLED_APPS`` in ``settings.py``
+
+.. code-block:: py
+
+    INSTALLED_APPS = (
+        ...,
+        'django_cleanup.apps.CleanupConfig',
+    )
+
+That is all, no other configuration is necessary.
+
+Note: Order of ``INSTALLED_APPS`` is important. To ensure that exceptions inside other apps' signal
+handlers do not affect the integrity of file deletions within transactions, ``django_cleanup``
+should be placed last in ``INSTALLED_APPS``.
+
+Troubleshooting
+===============
+If you notice that ``django-cleanup`` is not removing files when expected, check that your models
+are being properly
+`loaded <https://docs.djangoproject.com/en/stable/ref/applications/#how-applications-are-loaded>`_:
+
+    You must define or import all models in your application's models.py or models/__init__.py.
+    Otherwise, the application registry may not be fully populated at this point, which could cause
+    the ORM to malfunction.
+
+If your models are not loaded, ``django-cleanup`` will not be able to discover their
+``FileField``'s.
+
+You can check if your ``Model`` is loaded by using
+
+.. code-block:: py
+
+    from django.apps import apps
+    apps.get_models()
+
+Known limitations
+=================
+
+Database should support transactions
+------------------------------------
+If you are using a database that does not support transactions you may lose files if a
+transaction will rollback at the right instance. This outcome is mitigated by our use of
+post_save and post_delete signals, and by following the recommended configuration in this README.
+This outcome will still occur if there are signals registered after app initialization and there are
+exceptions when those signals are handled. In this case, the old file will be lost and the new file
+will not be referenced in a model, though the new file will likely still exist on disk. If you are
+concerned about this behavior you will need another solution for old file deletion in your project.
+
+File referenced by multiple model instances
+-------------------------------------------
+This app is designed with the assumption that each file is referenced only once. If you are sharing
+a file over two or more model instances you will not have the desired functionality. Be cautious of
+copying model instances, as this will cause a file to be shared by more than one instance. If you
+want to reference a file from multiple models add a level of indirection. That is, use a separate
+file model that is referenced from other models through a foreign key. There are many file
+management apps already available in the django ecosystem that fulfill this behavior.
+
+Advanced
+========
+This section contains additional functionality that can be used to interact with django-cleanup for
+special cases.
+
+Signals
+-------
+To facilitate interactions with other django apps django-cleanup sends the following signals which
+can be imported from :code:`django_cleanup.signals`:
+
+- :code:`cleanup_pre_delete`: just before a file is deleted. Passes a :code:`file` keyword argument.
+- :code:`cleanup_post_delete`: just after a file is deleted. Passes a :code:`file` keyword argument.
+
+Signals example for sorl.thumbnail:
+
+.. code-block:: py
+
+    from django_cleanup.signals import cleanup_pre_delete
+    from sorl.thumbnail import delete
+
+    def sorl_delete(**kwargs):
+        delete(kwargs['file'])
+
+    cleanup_pre_delete.connect(sorl_delete)
+
+Refresh the cache
+-----------------
+There have been rare cases where the cache would need to be refreshed. To do so the
+:code:`django_cleanup.cleanup.refresh` method can be used:
+
+.. code-block:: py
+
+    from django_cleanup import cleanup
+
+    cleanup.refresh(model_instance)
+
+Ignore cleanup for a specific model
+-----------------------------------
+To ignore a model and not have cleanup performed when the model is deleted or its files change, use
+the :code:`ignore` decorator to mark that model:
+
+.. code-block:: py
+
+    from django_cleanup import cleanup
+
+    @cleanup.ignore
+    class MyModel(models.Model):
+        image = models.FileField()
+
+Only cleanup selected models
+----------------------------
+If you have many models to ignore, or if you prefer to be explicit about what models are selected,
+you can change the mode of django-cleanup to "select mode" by using the select mode app config. In
+your ``INSTALLED_APPS`` setting you will replace ':code:`django_cleanup.apps.CleanupConfig`'
+with ':code:`django_cleanup.apps.CleanupSelectedConfig`'. Then use the :code:`select` decorator to
+mark a model for cleanup:
+
+.. code-block:: py
+
+    from django_cleanup import cleanup
+
+    @cleanup.select
+    class MyModel(models.Model):
+        image = models.FileField()
+
+How to run tests
+================
+Install, setup and use pyenv_ to install all the required versions of cPython
+(see the `tox.ini <https://github.com/un1t/django-cleanup/blob/master/tox.ini>`_).
+
+Setup pyenv_ to have all versions of python activated within your local django-cleanup repository.
+Ensuring that the python 3.10 that was installed is first priority.
+
+Install tox_ on python 3.10 and run the :code:`tox` command from your local django-cleanup
+repository.
+
+How to write tests
+==================
+This app requires the use of django.test.TransactionTestCase_ when writing tests.
+
+For details on why this is required see `here
+<https://docs.djangoproject.com/en/stable/topics/db/transactions/#use-in-tests>`_:
+
+    Django's :code:`TestCase` class wraps each test in a transaction and rolls back that transaction
+    after each test, in order to provide test isolation. This means that no transaction is ever
+    actually committed, thus your :code:`on_commit()` callbacks will never be run. If you need to
+    test the results of an :code:`on_commit()` callback, use a :code:`TransactionTestCase` instead.
+
+License
+=======
+django-cleanup is free software under terms of the:
+
+MIT License
+
+Copyright (C) 2012 by Ilya Shalyapin, ishalyapin@gmail.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+.. _django.test.TransactionTestCase: https://docs.djangoproject.com/en/stable/topics/testing/tools/#django.test.TransactionTestCase
+.. _pyenv: https://github.com/pyenv/pyenv
+.. _tox: https://tox.readthedocs.io/en/latest/
+
+.. |Version| image:: https://img.shields.io/pypi/v/django-cleanup.svg
+   :target: https://pypi.python.org/pypi/django-cleanup/
+   :alt: PyPI Package
+.. |Status| image:: https://github.com/un1t/django-cleanup/actions/workflows/main.yml/badge.svg
+   :target: https://github.com/un1t/django-cleanup/actions/workflows/main.yml
+   :alt: Build Status
+.. |License| image:: https://img.shields.io/badge/license-MIT-maroon
+   :target: https://github.com/un1t/django-cleanup/blob/master/LICENSE
+   :alt: MIT License
diff --git a/README.rst b/README.rst
index 6314345..6fea595 100644
--- a/README.rst
+++ b/README.rst
@@ -11,8 +11,8 @@ is set as the :code:`FileField`'s default value will not be deleted.
 
 Compatibility
 -------------
-- Django 2.2, 3.0, 3.1, 3.2 (`See Django Supported Versions <https://www.djangoproject.com/download/#supported-versions>`_)
-- Python 3.5+
+- Django 3.2, 4.0, 4.1 (`See Django Supported Versions <https://www.djangoproject.com/download/#supported-versions>`_)
+- Python 3.6+
 - Compatible with `sorl-thumbnail <https://github.com/jazzband/sorl-thumbnail>`_
 - Compatible with `easy-thumbnail <https://github.com/SmileyChris/easy-thumbnails>`_
 
@@ -25,13 +25,7 @@ whether or not a :code:`FileField`'s value has changed a local cache of original
 the model instance. If a condition is detected that should result in a file deletion, a function to
 delete the file is setup and inserted into the commit phase of the current transaction.
 
-**Warning! If you are using a database that does not support transactions you may lose files if a
-transaction will rollback at the right instance. This outcome is mitigated by our use of
-post_save and post_delete signals, and by following the recommended configuration below. This
-outcome will still occur if there are signals registered after app initialization and there are
-exceptions when those signals are handled. In this case, the old file will be lost and the new file
-will not be referenced in a model, though the new file will likely still exist on disk. If you are
-concerned about this behavior you will need another solution for old file deletion in your project.**
+**Warning! Please be aware of the known limitations documented below!**
 
 Installation
 ============
@@ -77,6 +71,28 @@ You can check if your ``Model`` is loaded by using
     from django.apps import apps
     apps.get_models()
 
+Known limitations
+=================
+
+Database should support transactions
+------------------------------------
+If you are using a database that does not support transactions you may lose files if a
+transaction will rollback at the right instance. This outcome is mitigated by our use of
+post_save and post_delete signals, and by following the recommended configuration in this README.
+This outcome will still occur if there are signals registered after app initialization and there are
+exceptions when those signals are handled. In this case, the old file will be lost and the new file
+will not be referenced in a model, though the new file will likely still exist on disk. If you are
+concerned about this behavior you will need another solution for old file deletion in your project.
+
+File referenced by multiple model instances
+-------------------------------------------
+This app is designed with the assumption that each file is referenced only once. If you are sharing
+a file over two or more model instances you will not have the desired functionality. Be cautious of
+copying model instances, as this will cause a file to be shared by more than one instance. If you
+want to reference a file from multiple models add a level of indirection. That is, use a separate
+file model that is referenced from other models through a foreign key. There are many file
+management apps already available in the django ecosystem that fulfill this behavior.
+
 Advanced
 ========
 This section contains additional functionality that can be used to interact with django-cleanup for
@@ -115,7 +131,8 @@ There have been rare cases where the cache would need to be refreshed. To do so
 
 Ignore cleanup for a specific model
 -----------------------------------
-Ignore a model and do not perform cleanup when the model is deleted or its files change.
+To ignore a model and not have cleanup performed when the model is deleted or its files change, use
+the :code:`ignore` decorator to mark that model:
 
 .. code-block:: py
 
@@ -125,15 +142,31 @@ Ignore a model and do not perform cleanup when the model is deleted or its files
     class MyModel(models.Model):
         image = models.FileField()
 
+Only cleanup selected models
+----------------------------
+If you have many models to ignore, or if you prefer to be explicit about what models are selected,
+you can change the mode of django-cleanup to "select mode" by using the select mode app config. In
+your ``INSTALLED_APPS`` setting you will replace ':code:`django_cleanup.apps.CleanupConfig`'
+with ':code:`django_cleanup.apps.CleanupSelectedConfig`'. Then use the :code:`select` decorator to
+mark a model for cleanup:
+
+.. code-block:: py
+
+    from django_cleanup import cleanup
+
+    @cleanup.select
+    class MyModel(models.Model):
+        image = models.FileField()
+
 How to run tests
 ================
 Install, setup and use pyenv_ to install all the required versions of cPython
 (see the `tox.ini <https://github.com/un1t/django-cleanup/blob/master/tox.ini>`_).
 
 Setup pyenv_ to have all versions of python activated within your local django-cleanup repository.
-Ensuring that the python 3.8 that was installed is first priority.
+Ensuring that the python 3.10 that was installed is first priority.
 
-Install tox_ on python 3.8 and run the :code:`tox` command from your local django-cleanup
+Install tox_ on python 3.10 and run the :code:`tox` command from your local django-cleanup
 repository.
 
 How to write tests
@@ -182,8 +215,8 @@ SOFTWARE.
 .. |Version| image:: https://img.shields.io/pypi/v/django-cleanup.svg
    :target: https://pypi.python.org/pypi/django-cleanup/
    :alt: PyPI Package
-.. |Status| image:: https://travis-ci.org/un1t/django-cleanup.svg?branch=master
-   :target: https://travis-ci.org/un1t/django-cleanup
+.. |Status| image:: https://github.com/un1t/django-cleanup/actions/workflows/main.yml/badge.svg
+   :target: https://github.com/un1t/django-cleanup/actions/workflows/main.yml
    :alt: Build Status
 .. |License| image:: https://img.shields.io/badge/license-MIT-maroon
    :target: https://github.com/un1t/django-cleanup/blob/master/LICENSE
diff --git a/debian/changelog b/debian/changelog
index 3f88bae..bfff161 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,8 +1,10 @@
-django-cleanup (5.2.0-3) UNRELEASED; urgency=medium
+django-cleanup (7.0.0-1) UNRELEASED; urgency=medium
 
   * Update standards version to 4.6.1, no changes needed.
+  * New upstream release.
+  * New upstream release.
 
- -- Debian Janitor <janitor@jelmer.uk>  Fri, 28 Oct 2022 06:54:45 -0000
+ -- Debian Janitor <janitor@jelmer.uk>  Fri, 26 May 2023 21:31:58 -0000
 
 django-cleanup (5.2.0-2) unstable; urgency=medium
 
diff --git a/django_cleanup.egg-info/PKG-INFO b/django_cleanup.egg-info/PKG-INFO
deleted file mode 100644
index 7f158d8..0000000
--- a/django_cleanup.egg-info/PKG-INFO
+++ /dev/null
@@ -1,225 +0,0 @@
-Metadata-Version: 2.1
-Name: django-cleanup
-Version: 5.2.0
-Summary: Deletes old files.
-Home-page: https://github.com/un1t/django-cleanup
-Author: Ilya Shalyapin
-Author-email: ishalyapin@gmail.com
-License: MIT License
-Download-URL: https://github.com/un1t/django-cleanup/tarball/master
-Description: Django Cleanup
-        **************
-        |Version| |Status| |License|
-        
-        Features
-        ========
-        The django-cleanup app automatically deletes files for :code:`FileField`, :code:`ImageField` and
-        subclasses. When a :code:`FileField`'s value is changed and the model is saved, the old file is
-        deleted. When a model that has a :code:`FileField` is deleted, the file is also deleted. A file that
-        is set as the :code:`FileField`'s default value will not be deleted.
-        
-        Compatibility
-        -------------
-        - Django 2.2, 3.0, 3.1, 3.2 (`See Django Supported Versions <https://www.djangoproject.com/download/#supported-versions>`_)
-        - Python 3.5+
-        - Compatible with `sorl-thumbnail <https://github.com/jazzband/sorl-thumbnail>`_
-        - Compatible with `easy-thumbnail <https://github.com/SmileyChris/easy-thumbnails>`_
-        
-        How does it work?
-        =================
-        In order to track changes of a :code:`FileField` and facilitate file deletions, django-cleanup
-        connects :code:`post_init`, :code:`pre_save`, :code:`post_save` and :code:`post_delete` signals to
-        signal handlers for each :code:`INSTALLED_APPS` model that has a :code:`FileField`. In order to tell
-        whether or not a :code:`FileField`'s value has changed a local cache of original values is kept on
-        the model instance. If a condition is detected that should result in a file deletion, a function to
-        delete the file is setup and inserted into the commit phase of the current transaction.
-        
-        **Warning! If you are using a database that does not support transactions you may lose files if a
-        transaction will rollback at the right instance. This outcome is mitigated by our use of
-        post_save and post_delete signals, and by following the recommended configuration below. This
-        outcome will still occur if there are signals registered after app initialization and there are
-        exceptions when those signals are handled. In this case, the old file will be lost and the new file
-        will not be referenced in a model, though the new file will likely still exist on disk. If you are
-        concerned about this behavior you will need another solution for old file deletion in your project.**
-        
-        Installation
-        ============
-        ::
-        
-            pip install django-cleanup
-        
-        
-        Configuration
-        =============
-        Add ``django_cleanup`` to the bottom of ``INSTALLED_APPS`` in ``settings.py``
-        
-        .. code-block:: py
-        
-            INSTALLED_APPS = (
-                ...,
-                'django_cleanup.apps.CleanupConfig',
-            )
-        
-        That is all, no other configuration is necessary.
-        
-        Note: Order of ``INSTALLED_APPS`` is important. To ensure that exceptions inside other apps' signal
-        handlers do not affect the integrity of file deletions within transactions, ``django_cleanup``
-        should be placed last in ``INSTALLED_APPS``.
-        
-        Troubleshooting
-        ===============
-        If you notice that ``django-cleanup`` is not removing files when expected, check that your models
-        are being properly
-        `loaded <https://docs.djangoproject.com/en/stable/ref/applications/#how-applications-are-loaded>`_:
-        
-            You must define or import all models in your application's models.py or models/__init__.py.
-            Otherwise, the application registry may not be fully populated at this point, which could cause
-            the ORM to malfunction.
-        
-        If your models are not loaded, ``django-cleanup`` will not be able to discover their
-        ``FileField``'s.
-        
-        You can check if your ``Model`` is loaded by using
-        
-        .. code-block:: py
-        
-            from django.apps import apps
-            apps.get_models()
-        
-        Advanced
-        ========
-        This section contains additional functionality that can be used to interact with django-cleanup for
-        special cases.
-        
-        Signals
-        -------
-        To facilitate interactions with other django apps django-cleanup sends the following signals which
-        can be imported from :code:`django_cleanup.signals`:
-        
-        - :code:`cleanup_pre_delete`: just before a file is deleted. Passes a :code:`file` keyword argument.
-        - :code:`cleanup_post_delete`: just after a file is deleted. Passes a :code:`file` keyword argument.
-        
-        Signals example for sorl.thumbnail:
-        
-        .. code-block:: py
-        
-            from django_cleanup.signals import cleanup_pre_delete
-            from sorl.thumbnail import delete
-        
-            def sorl_delete(**kwargs):
-                delete(kwargs['file'])
-        
-            cleanup_pre_delete.connect(sorl_delete)
-        
-        Refresh the cache
-        -----------------
-        There have been rare cases where the cache would need to be refreshed. To do so the
-        :code:`django_cleanup.cleanup.refresh` method can be used:
-        
-        .. code-block:: py
-        
-            from django_cleanup import cleanup
-        
-            cleanup.refresh(model_instance)
-        
-        Ignore cleanup for a specific model
-        -----------------------------------
-        Ignore a model and do not perform cleanup when the model is deleted or its files change.
-        
-        .. code-block:: py
-        
-            from django_cleanup import cleanup
-        
-            @cleanup.ignore
-            class MyModel(models.Model):
-                image = models.FileField()
-        
-        How to run tests
-        ================
-        Install, setup and use pyenv_ to install all the required versions of cPython
-        (see the `tox.ini <https://github.com/un1t/django-cleanup/blob/master/tox.ini>`_).
-        
-        Setup pyenv_ to have all versions of python activated within your local django-cleanup repository.
-        Ensuring that the python 3.8 that was installed is first priority.
-        
-        Install tox_ on python 3.8 and run the :code:`tox` command from your local django-cleanup
-        repository.
-        
-        How to write tests
-        ==================
-        This app requires the use of django.test.TransactionTestCase_ when writing tests.
-        
-        For details on why this is required see `here
-        <https://docs.djangoproject.com/en/stable/topics/db/transactions/#use-in-tests>`_:
-        
-            Django's :code:`TestCase` class wraps each test in a transaction and rolls back that transaction
-            after each test, in order to provide test isolation. This means that no transaction is ever
-            actually committed, thus your :code:`on_commit()` callbacks will never be run. If you need to
-            test the results of an :code:`on_commit()` callback, use a :code:`TransactionTestCase` instead.
-        
-        License
-        =======
-        django-cleanup is free software under terms of the:
-        
-        MIT License
-        
-        Copyright (C) 2012 by Ilya Shalyapin, ishalyapin@gmail.com
-        
-        Permission is hereby granted, free of charge, to any person obtaining a copy
-        of this software and associated documentation files (the "Software"), to deal
-        in the Software without restriction, including without limitation the rights
-        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-        copies of the Software, and to permit persons to whom the Software is
-        furnished to do so, subject to the following conditions:
-        
-        The above copyright notice and this permission notice shall be included in all
-        copies or substantial portions of the Software.
-        
-        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-        SOFTWARE.
-        
-        
-        .. _django.test.TransactionTestCase: https://docs.djangoproject.com/en/stable/topics/testing/tools/#django.test.TransactionTestCase
-        .. _pyenv: https://github.com/pyenv/pyenv
-        .. _tox: https://tox.readthedocs.io/en/latest/
-        
-        .. |Version| image:: https://img.shields.io/pypi/v/django-cleanup.svg
-           :target: https://pypi.python.org/pypi/django-cleanup/
-           :alt: PyPI Package
-        .. |Status| image:: https://travis-ci.org/un1t/django-cleanup.svg?branch=master
-           :target: https://travis-ci.org/un1t/django-cleanup
-           :alt: Build Status
-        .. |License| image:: https://img.shields.io/badge/license-MIT-maroon
-           :target: https://github.com/un1t/django-cleanup/blob/master/LICENSE
-           :alt: MIT License
-        
-Keywords: django
-Platform: UNKNOWN
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: Environment :: Web Environment
-Classifier: Framework :: Django
-Classifier: Framework :: Django :: 2.2
-Classifier: Framework :: Django :: 3.0
-Classifier: Framework :: Django :: 3.1
-Classifier: Framework :: Django :: 3.2
-Classifier: Intended Audience :: Developers
-Classifier: License :: OSI Approved :: MIT License
-Classifier: Operating System :: OS Independent
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.5
-Classifier: Programming Language :: Python :: 3.6
-Classifier: Programming Language :: Python :: 3.7
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: Implementation :: CPython
-Classifier: Programming Language :: Python :: Implementation :: PyPy
-Classifier: Topic :: Utilities
-Requires: python (>=3.5)
-Requires: django (>=2.2)
-Description-Content-Type: text/x-rst
diff --git a/django_cleanup.egg-info/SOURCES.txt b/django_cleanup.egg-info/SOURCES.txt
deleted file mode 100644
index c92e32f..0000000
--- a/django_cleanup.egg-info/SOURCES.txt
+++ /dev/null
@@ -1,16 +0,0 @@
-CHANGELOG.md
-LICENSE
-MANIFEST.in
-README.rst
-setup.cfg
-setup.py
-django_cleanup/__init__.py
-django_cleanup/apps.py
-django_cleanup/cache.py
-django_cleanup/cleanup.py
-django_cleanup/handlers.py
-django_cleanup/signals.py
-django_cleanup.egg-info/PKG-INFO
-django_cleanup.egg-info/SOURCES.txt
-django_cleanup.egg-info/dependency_links.txt
-django_cleanup.egg-info/top_level.txt
\ No newline at end of file
diff --git a/django_cleanup/cleanup.py b/django_cleanup/cleanup.py
deleted file mode 100644
index ad7a163..0000000
--- a/django_cleanup/cleanup.py
+++ /dev/null
@@ -1,18 +0,0 @@
-'''Public utilities'''
-from .cache import (
-    get_mangled_ignore as _get_mangled_ignore, make_cleanup_cache as _make_cleanup_cache)
-
-
-__all__ = ['refresh', 'cleanup_ignore']
-
-
-def refresh(instance):
-    '''Refresh the cache for an instance'''
-    return _make_cleanup_cache(instance)
-
-
-def ignore(cls):
-    '''Mark a model to ignore for cleanup'''
-    setattr(cls, _get_mangled_ignore(cls), None)
-    return cls
-cleanup_ignore = ignore
diff --git a/setup.py b/setup.py
index c902f20..cd318db 100644
--- a/setup.py
+++ b/setup.py
@@ -29,10 +29,11 @@ def find_version(*parts):
 
 setup(
     name='django-cleanup',
-    version=find_version('django_cleanup', '__init__.py'),
+    version=find_version('src/django_cleanup', '__init__.py'),
     packages=['django_cleanup'],
+    package_dir={'': 'src'},
     include_package_data=True,
-    requires=['python (>=3.5)', 'django (>=2.2)'],
+    requires=['python (>=3.6)', 'django (>=3.2)'],
     description='Deletes old files.',
     long_description=read('README.rst'),
     long_description_content_type='text/x-rst',
@@ -46,20 +47,20 @@ setup(
         'Development Status :: 5 - Production/Stable',
         'Environment :: Web Environment',
         'Framework :: Django',
-        'Framework :: Django :: 2.2',
-        'Framework :: Django :: 3.0',
-        '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',
         'Programming Language :: Python',
         'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.5',
         'Programming Language :: Python :: 3.6',
         'Programming Language :: Python :: 3.7',
         'Programming Language :: Python :: 3.8',
         'Programming Language :: Python :: 3.9',
+        'Programming Language :: Python :: 3.10',
+        'Programming Language :: Python :: 3.11',
         'Programming Language :: Python :: Implementation :: CPython',
         'Programming Language :: Python :: Implementation :: PyPy',
         'Topic :: Utilities',
diff --git a/src/django_cleanup.egg-info/PKG-INFO b/src/django_cleanup.egg-info/PKG-INFO
new file mode 100644
index 0000000..98bf080
--- /dev/null
+++ b/src/django_cleanup.egg-info/PKG-INFO
@@ -0,0 +1,258 @@
+Metadata-Version: 2.1
+Name: django-cleanup
+Version: 7.0.0
+Summary: Deletes old files.
+Home-page: https://github.com/un1t/django-cleanup
+Download-URL: https://github.com/un1t/django-cleanup/tarball/master
+Author: Ilya Shalyapin
+Author-email: ishalyapin@gmail.com
+License: MIT License
+Keywords: django
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Environment :: Web Environment
+Classifier: Framework :: Django
+Classifier: Framework :: Django :: 3.2
+Classifier: Framework :: Django :: 4.0
+Classifier: Framework :: Django :: 4.1
+Classifier: Intended Audience :: Developers
+Classifier: License :: OSI Approved :: MIT License
+Classifier: Operating System :: OS Independent
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
+Classifier: Programming Language :: Python :: 3.8
+Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: Implementation :: CPython
+Classifier: Programming Language :: Python :: Implementation :: PyPy
+Classifier: Topic :: Utilities
+Requires: python (>=3.6)
+Requires: django (>=3.2)
+Description-Content-Type: text/x-rst
+License-File: LICENSE
+
+Django Cleanup
+**************
+|Version| |Status| |License|
+
+Features
+========
+The django-cleanup app automatically deletes files for :code:`FileField`, :code:`ImageField` and
+subclasses. When a :code:`FileField`'s value is changed and the model is saved, the old file is
+deleted. When a model that has a :code:`FileField` is deleted, the file is also deleted. A file that
+is set as the :code:`FileField`'s default value will not be deleted.
+
+Compatibility
+-------------
+- Django 3.2, 4.0, 4.1 (`See Django Supported Versions <https://www.djangoproject.com/download/#supported-versions>`_)
+- Python 3.6+
+- Compatible with `sorl-thumbnail <https://github.com/jazzband/sorl-thumbnail>`_
+- Compatible with `easy-thumbnail <https://github.com/SmileyChris/easy-thumbnails>`_
+
+How does it work?
+=================
+In order to track changes of a :code:`FileField` and facilitate file deletions, django-cleanup
+connects :code:`post_init`, :code:`pre_save`, :code:`post_save` and :code:`post_delete` signals to
+signal handlers for each :code:`INSTALLED_APPS` model that has a :code:`FileField`. In order to tell
+whether or not a :code:`FileField`'s value has changed a local cache of original values is kept on
+the model instance. If a condition is detected that should result in a file deletion, a function to
+delete the file is setup and inserted into the commit phase of the current transaction.
+
+**Warning! Please be aware of the known limitations documented below!**
+
+Installation
+============
+::
+
+    pip install django-cleanup
+
+
+Configuration
+=============
+Add ``django_cleanup`` to the bottom of ``INSTALLED_APPS`` in ``settings.py``
+
+.. code-block:: py
+
+    INSTALLED_APPS = (
+        ...,
+        'django_cleanup.apps.CleanupConfig',
+    )
+
+That is all, no other configuration is necessary.
+
+Note: Order of ``INSTALLED_APPS`` is important. To ensure that exceptions inside other apps' signal
+handlers do not affect the integrity of file deletions within transactions, ``django_cleanup``
+should be placed last in ``INSTALLED_APPS``.
+
+Troubleshooting
+===============
+If you notice that ``django-cleanup`` is not removing files when expected, check that your models
+are being properly
+`loaded <https://docs.djangoproject.com/en/stable/ref/applications/#how-applications-are-loaded>`_:
+
+    You must define or import all models in your application's models.py or models/__init__.py.
+    Otherwise, the application registry may not be fully populated at this point, which could cause
+    the ORM to malfunction.
+
+If your models are not loaded, ``django-cleanup`` will not be able to discover their
+``FileField``'s.
+
+You can check if your ``Model`` is loaded by using
+
+.. code-block:: py
+
+    from django.apps import apps
+    apps.get_models()
+
+Known limitations
+=================
+
+Database should support transactions
+------------------------------------
+If you are using a database that does not support transactions you may lose files if a
+transaction will rollback at the right instance. This outcome is mitigated by our use of
+post_save and post_delete signals, and by following the recommended configuration in this README.
+This outcome will still occur if there are signals registered after app initialization and there are
+exceptions when those signals are handled. In this case, the old file will be lost and the new file
+will not be referenced in a model, though the new file will likely still exist on disk. If you are
+concerned about this behavior you will need another solution for old file deletion in your project.
+
+File referenced by multiple model instances
+-------------------------------------------
+This app is designed with the assumption that each file is referenced only once. If you are sharing
+a file over two or more model instances you will not have the desired functionality. Be cautious of
+copying model instances, as this will cause a file to be shared by more than one instance. If you
+want to reference a file from multiple models add a level of indirection. That is, use a separate
+file model that is referenced from other models through a foreign key. There are many file
+management apps already available in the django ecosystem that fulfill this behavior.
+
+Advanced
+========
+This section contains additional functionality that can be used to interact with django-cleanup for
+special cases.
+
+Signals
+-------
+To facilitate interactions with other django apps django-cleanup sends the following signals which
+can be imported from :code:`django_cleanup.signals`:
+
+- :code:`cleanup_pre_delete`: just before a file is deleted. Passes a :code:`file` keyword argument.
+- :code:`cleanup_post_delete`: just after a file is deleted. Passes a :code:`file` keyword argument.
+
+Signals example for sorl.thumbnail:
+
+.. code-block:: py
+
+    from django_cleanup.signals import cleanup_pre_delete
+    from sorl.thumbnail import delete
+
+    def sorl_delete(**kwargs):
+        delete(kwargs['file'])
+
+    cleanup_pre_delete.connect(sorl_delete)
+
+Refresh the cache
+-----------------
+There have been rare cases where the cache would need to be refreshed. To do so the
+:code:`django_cleanup.cleanup.refresh` method can be used:
+
+.. code-block:: py
+
+    from django_cleanup import cleanup
+
+    cleanup.refresh(model_instance)
+
+Ignore cleanup for a specific model
+-----------------------------------
+To ignore a model and not have cleanup performed when the model is deleted or its files change, use
+the :code:`ignore` decorator to mark that model:
+
+.. code-block:: py
+
+    from django_cleanup import cleanup
+
+    @cleanup.ignore
+    class MyModel(models.Model):
+        image = models.FileField()
+
+Only cleanup selected models
+----------------------------
+If you have many models to ignore, or if you prefer to be explicit about what models are selected,
+you can change the mode of django-cleanup to "select mode" by using the select mode app config. In
+your ``INSTALLED_APPS`` setting you will replace ':code:`django_cleanup.apps.CleanupConfig`'
+with ':code:`django_cleanup.apps.CleanupSelectedConfig`'. Then use the :code:`select` decorator to
+mark a model for cleanup:
+
+.. code-block:: py
+
+    from django_cleanup import cleanup
+
+    @cleanup.select
+    class MyModel(models.Model):
+        image = models.FileField()
+
+How to run tests
+================
+Install, setup and use pyenv_ to install all the required versions of cPython
+(see the `tox.ini <https://github.com/un1t/django-cleanup/blob/master/tox.ini>`_).
+
+Setup pyenv_ to have all versions of python activated within your local django-cleanup repository.
+Ensuring that the python 3.10 that was installed is first priority.
+
+Install tox_ on python 3.10 and run the :code:`tox` command from your local django-cleanup
+repository.
+
+How to write tests
+==================
+This app requires the use of django.test.TransactionTestCase_ when writing tests.
+
+For details on why this is required see `here
+<https://docs.djangoproject.com/en/stable/topics/db/transactions/#use-in-tests>`_:
+
+    Django's :code:`TestCase` class wraps each test in a transaction and rolls back that transaction
+    after each test, in order to provide test isolation. This means that no transaction is ever
+    actually committed, thus your :code:`on_commit()` callbacks will never be run. If you need to
+    test the results of an :code:`on_commit()` callback, use a :code:`TransactionTestCase` instead.
+
+License
+=======
+django-cleanup is free software under terms of the:
+
+MIT License
+
+Copyright (C) 2012 by Ilya Shalyapin, ishalyapin@gmail.com
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+.. _django.test.TransactionTestCase: https://docs.djangoproject.com/en/stable/topics/testing/tools/#django.test.TransactionTestCase
+.. _pyenv: https://github.com/pyenv/pyenv
+.. _tox: https://tox.readthedocs.io/en/latest/
+
+.. |Version| image:: https://img.shields.io/pypi/v/django-cleanup.svg
+   :target: https://pypi.python.org/pypi/django-cleanup/
+   :alt: PyPI Package
+.. |Status| image:: https://github.com/un1t/django-cleanup/actions/workflows/main.yml/badge.svg
+   :target: https://github.com/un1t/django-cleanup/actions/workflows/main.yml
+   :alt: Build Status
+.. |License| image:: https://img.shields.io/badge/license-MIT-maroon
+   :target: https://github.com/un1t/django-cleanup/blob/master/LICENSE
+   :alt: MIT License
diff --git a/src/django_cleanup.egg-info/SOURCES.txt b/src/django_cleanup.egg-info/SOURCES.txt
new file mode 100644
index 0000000..be7154b
--- /dev/null
+++ b/src/django_cleanup.egg-info/SOURCES.txt
@@ -0,0 +1,19 @@
+CHANGELOG.md
+LICENSE
+MANIFEST.in
+README.rst
+setup.cfg
+setup.py
+src/django_cleanup/__init__.py
+src/django_cleanup/apps.py
+src/django_cleanup/cache.py
+src/django_cleanup/cleanup.py
+src/django_cleanup/handlers.py
+src/django_cleanup/signals.py
+src/django_cleanup.egg-info/PKG-INFO
+src/django_cleanup.egg-info/SOURCES.txt
+src/django_cleanup.egg-info/dependency_links.txt
+src/django_cleanup.egg-info/top_level.txt
+test/test_all.py
+test/test_integration.py
+test/testing_helpers.py
\ No newline at end of file
diff --git a/django_cleanup.egg-info/dependency_links.txt b/src/django_cleanup.egg-info/dependency_links.txt
similarity index 100%
rename from django_cleanup.egg-info/dependency_links.txt
rename to src/django_cleanup.egg-info/dependency_links.txt
diff --git a/django_cleanup.egg-info/top_level.txt b/src/django_cleanup.egg-info/top_level.txt
similarity index 100%
rename from django_cleanup.egg-info/top_level.txt
rename to src/django_cleanup.egg-info/top_level.txt
diff --git a/django_cleanup/__init__.py b/src/django_cleanup/__init__.py
similarity index 73%
rename from django_cleanup/__init__.py
rename to src/django_cleanup/__init__.py
index ee849fa..89b31a9 100644
--- a/django_cleanup/__init__.py
+++ b/src/django_cleanup/__init__.py
@@ -3,5 +3,5 @@
     subclasses. It will delete old files when a new file is being save and it
     will delete files on model instance deletion.
 '''
-__version__ = '5.2.0'
-default_app_config = 'django_cleanup.apps.CleanupConfig'
+
+__version__ = '7.0.0'
diff --git a/django_cleanup/apps.py b/src/django_cleanup/apps.py
similarity index 56%
rename from django_cleanup/apps.py
rename to src/django_cleanup/apps.py
index fd22910..002965f 100644
--- a/django_cleanup/apps.py
+++ b/src/django_cleanup/apps.py
@@ -9,7 +9,16 @@ from . import cache, handlers
 class CleanupConfig(AppConfig):
     name = 'django_cleanup'
     verbose_name = 'Django Cleanup'
+    default = True
 
     def ready(self):
-        cache.prepare()
+        cache.prepare(False)
+        handlers.connect()
+
+class CleanupSelectedConfig(AppConfig):
+    name = 'django_cleanup'
+    verbose_name = 'Django Cleanup'
+
+    def ready(self):
+        cache.prepare(True)
         handlers.connect()
diff --git a/django_cleanup/cache.py b/src/django_cleanup/cache.py
similarity index 89%
rename from django_cleanup/cache.py
rename to src/django_cleanup/cache.py
index b34fcf9..a2e3423 100644
--- a/django_cleanup/cache.py
+++ b/src/django_cleanup/cache.py
@@ -24,13 +24,13 @@ DOTTED_PATH = '{klass.__module__}.{klass.__qualname__}'
 # cache init ##
 
 
-def prepare():
+def prepare(select_mode):
     '''Prepare the cache for all models, non-reentrant'''
     if FIELDS:  # pragma: no cover
         return
 
     for model in apps.get_models():
-        if ignore_model(model):
+        if ignore_model(model, select_mode):
             continue
         name = get_model_name(model)
         if model_has_filefields(name):  # pragma: no cover
@@ -113,10 +113,15 @@ def get_model_name(model):
 
 
 def get_mangled_ignore(model):
-    '''returns a mangled attribute name specific to the model'''
+    '''returns a mangled attribute name specific to the model for ignore functionality'''
     return '_{opt.model_name}__{opt.app_label}_cleanup_ignore'.format(opt=model._meta)
 
 
+def get_mangled_select(model):
+    '''returns a mangled attribute name specific to the model for select functionality'''
+    return '_{opt.model_name}__{opt.app_label}_cleanup_select'.format(opt=model._meta)
+
+
 # booleans ##
 
 
@@ -125,9 +130,9 @@ def model_has_filefields(model_name):
     return model_name in FIELDS
 
 
-def ignore_model(model):
+def ignore_model(model, select_mode):
     '''Check if a model should be ignored'''
-    return hasattr(model, get_mangled_ignore(model))
+    return (not hasattr(model, get_mangled_select(model))) if select_mode else hasattr(model, get_mangled_ignore(model))
 
 
 # instance functions ##
diff --git a/src/django_cleanup/cleanup.py b/src/django_cleanup/cleanup.py
new file mode 100644
index 0000000..eaa17b3
--- /dev/null
+++ b/src/django_cleanup/cleanup.py
@@ -0,0 +1,26 @@
+'''Public utilities'''
+from .cache import (
+    get_mangled_ignore as _get_mangled_ignore, get_mangled_select as _get_mangled_select,
+    make_cleanup_cache as _make_cleanup_cache)
+
+
+__all__ = ['refresh', 'cleanup_ignore', 'cleanup_select']
+
+
+def refresh(instance):
+    '''Refresh the cache for an instance'''
+    return _make_cleanup_cache(instance)
+
+
+def ignore(cls):
+    '''Mark a model to ignore for cleanup'''
+    setattr(cls, _get_mangled_ignore(cls), None)
+    return cls
+cleanup_ignore = ignore
+
+
+def select(cls):
+    '''Mark a model to select for cleanup'''
+    setattr(cls, _get_mangled_select(cls), None)
+    return cls
+cleanup_select = select
diff --git a/django_cleanup/handlers.py b/src/django_cleanup/handlers.py
similarity index 83%
rename from django_cleanup/handlers.py
rename to src/django_cleanup/handlers.py
index e40240c..aa6a8c5 100644
--- a/django_cleanup/handlers.py
+++ b/src/django_cleanup/handlers.py
@@ -46,7 +46,7 @@ def delete_old_post_save(sender, instance, raw, created, update_fields, using,
             if update_fields is None or field_name in update_fields:
                 old_file = cache.get_field_attr(instance, field_name)
                 if old_file != new_file:
-                    delete_file(instance, field_name, old_file, using)
+                    delete_file(sender, instance, field_name, old_file, using, 'updated')
 
     # reset cache
     cache.make_cleanup_cache(instance)
@@ -55,10 +55,10 @@ def delete_old_post_save(sender, instance, raw, created, update_fields, using,
 def delete_all_post_delete(sender, instance, using, **kwargs):
     '''Post_delete on all models with file fields, deletes all files'''
     for field_name, file_ in cache.fields_for_model_instance(instance):
-        delete_file(instance, field_name, file_, using)
+        delete_file(sender, instance, field_name, file_, using, 'deleted')
 
 
-def delete_file(instance, field_name, file_, using):
+def delete_file(sender, instance, field_name, file_, using, reason):
     '''Deletes a file'''
 
     if not file_.name:
@@ -88,19 +88,34 @@ def delete_file(instance, field_name, file_, using):
     if not hasattr(file_, 'storage'):
         file_.storage = cache.get_field_storage(model_name, field_name)()
 
+    event = {
+        'deleted': reason == 'deleted',
+        'model_name': model_name,
+        'field_name': field_name,
+        'file_name': file_.name,
+        'default_file_name': default,
+        'file': file_,
+        'instance': instance,
+        'updated': reason == 'updated'
+    }
+
     # this will run after a successful commit
     # assuming you are in a transaction and on a database that supports
     # transactions, otherwise it will run immediately
     def run_on_commit():
-        cleanup_pre_delete.send(sender=None, file=file_)
+        cleanup_pre_delete.send(sender=sender, **event)
+        success = False
+        error = None
         try:
             file_.delete(save=False)
-        except Exception:
+            success = True
+        except Exception as ex:
+            error = ex
             opts = instance._meta
             logger.exception(
                 'There was an exception deleting the file `%s` on field `%s.%s.%s`',
                 file_, opts.app_label, opts.model_name, field_name)
-        cleanup_post_delete.send(sender=None, file=file_)
+        cleanup_post_delete.send(sender=sender, error=error, success=success, **event)
 
     on_commit(run_on_commit, using)
 
diff --git a/django_cleanup/signals.py b/src/django_cleanup/signals.py
similarity index 100%
rename from django_cleanup/signals.py
rename to src/django_cleanup/signals.py
diff --git a/test/test_all.py b/test/test_all.py
new file mode 100644
index 0000000..1f1692a
--- /dev/null
+++ b/test/test_all.py
@@ -0,0 +1,452 @@
+import logging
+import os
+import pickle
+import re
+import tempfile
+
+from django.conf import settings
+from django.core.files import File
+from django.db import transaction
+from django.db.models.fields import NOT_PROVIDED, files
+
+import pytest
+
+from django_cleanup import cache, handlers
+from django_cleanup.signals import cleanup_post_delete, cleanup_pre_delete
+
+from . import storage
+from .models.app import (
+    BranchProduct, Product, ProductIgnore, ProductProxy, ProductUnmanaged, RootProduct)
+from .testing_helpers import get_random_pic_name, get_using
+
+
+LINE = re.compile(r'line \d{1,3}')
+
+TB = '''Traceback (most recent call last):
+  File "{handlers}", line xxx, in run_on_commit
+    file_.delete(save=False)
+  File "{files}", line xxx, in delete
+    self.storage.delete(self.name)
+  File "{storage}", line xxx, in delete
+    os.remove(name)
+{error}: [Errno 2] No such file or directory: '{{picture}}\''''
+
+
+
+def getTraceback():
+    fileabspath = os.path.abspath
+    error = 'FileNotFoundError'
+
+    return TB.format(
+        handlers=fileabspath(handlers.__file__),
+        files=fileabspath(files.__file__),
+        storage=fileabspath(storage.__file__),
+        error=error)
+
+
+def _raise(message):
+    def _func(x):  # pragma: no cover
+        raise Exception(message)
+    return _func
+
+
+@pytest.mark.django_db(transaction=True)
+def test_refresh_from_db_without_refresh(picture):
+    product = Product.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    product.refresh_from_db()
+    assert id(product.image.instance) == id(product)
+    product.image = get_random_pic_name()
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_cache_gone(picture):
+    product = Product.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    product.image = get_random_pic_name()
+    cache.remove_instance_cache(product)
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_storage_gone(picture):
+    product = Product.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    product.image = get_random_pic_name()
+    product = pickle.loads(pickle.dumps(product))
+    assert hasattr(product.image, 'storage')
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_replace_file_with_file(picture):
+    product = Product.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    random_pic_name = get_random_pic_name()
+    product.image = random_pic_name
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+    assert product.image
+    new_image_path = os.path.join(settings.MEDIA_ROOT, random_pic_name)
+    assert product.image.path == new_image_path
+
+
+@pytest.mark.django_db(transaction=True)
+def test_replace_file_with_blank(picture):
+    product = Product.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    product.image = ''
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+    assert not product.image
+    assert product.image.name == ''
+
+
+@pytest.mark.django_db(transaction=True)
+def test_replace_file_with_none(picture):
+    product = Product.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    product.image = None
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+    assert not product.image
+    assert product.image.name is None
+
+
+@pytest.mark.django_db(transaction=True)
+def test_replace_file_proxy(picture):
+    product = ProductProxy.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    product.image = get_random_pic_name()
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_replace_file_unmanaged(picture):
+    product = ProductUnmanaged.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    product.image = get_random_pic_name()
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_replace_file_deferred(picture):
+    '''probably shouldn't save from a deferred model but someone might do it'''
+    product = Product.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    product_deferred = Product.objects.defer('image_default').get(id=product.id)
+    product_deferred.image = get_random_pic_name()
+    with transaction.atomic(get_using(product)):
+        product_deferred.save()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_remove_model_instance(picture):
+    product = Product.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    with transaction.atomic(get_using(product)):
+        product.delete()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_remove_model_instance_default(picture):
+    product = Product.objects.create()
+    assert product.image_default.path == picture['srcpath']
+    assert product.image_default_callable.path == picture['srcpath']
+    assert os.path.exists(picture['srcpath'])
+    with transaction.atomic(get_using(product)):
+        product.delete()
+    assert os.path.exists(picture['srcpath'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_replace_file_with_file_default(picture):
+    product = Product.objects.create()
+    assert os.path.exists(picture['srcpath'])
+    random_pic_name1 = get_random_pic_name()
+    random_pic_name2 = get_random_pic_name()
+    product.image_default = random_pic_name1
+    product.image_default_callable = random_pic_name2
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert os.path.exists(picture['srcpath'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_remove_model_instance_ignore(picture):
+    product = ProductIgnore.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    with transaction.atomic(get_using(product)):
+        product.delete()
+    assert os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_replace_file_with_file_ignore(picture):
+    product = ProductIgnore.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    random_pic_name = get_random_pic_name()
+    product.image = random_pic_name
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert os.path.exists(picture['path'])
+    assert product.image
+    new_image_path = os.path.join(settings.MEDIA_ROOT, random_pic_name)
+    assert product.image.path == new_image_path
+
+
+@pytest.mark.django_db(transaction=True)
+def test_remove_model_instance_proxy(picture):
+    product = ProductProxy.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    with transaction.atomic(get_using(product)):
+        product.delete()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_remove_model_instance_unmanaged(picture):
+    product = ProductUnmanaged.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    with transaction.atomic(get_using(product)):
+        product.delete()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_remove_model_instance_deferred(picture):
+    product = Product.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    product_deferred = Product.objects.defer('image_default').get(id=product.id)
+    with transaction.atomic(get_using(product)):
+        product_deferred.delete()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_remove_blank_file(monkeypatch):
+    product = Product.objects.create(image='')
+    monkeypatch.setattr(
+        product.image.storage, 'exists', _raise('should not call exists'))
+    monkeypatch.setattr(
+        product.image.storage, 'delete', _raise('should not call delete'))
+    with transaction.atomic(get_using(product)):
+        product.delete()
+
+
+@pytest.mark.django_db(transaction=True)
+def test_remove_not_exists():
+    product = Product.objects.create(image='no-such-file')
+    with transaction.atomic(get_using(product)):
+        product.delete()
+
+
+@pytest.mark.django_db(transaction=True)
+def test_remove_none(monkeypatch):
+    product = Product.objects.create(image=None)
+    monkeypatch.setattr(
+        product.image.storage, 'exists', _raise('should not call exists'))
+    monkeypatch.setattr(
+        product.image.storage, 'delete', _raise('should not call delete'))
+    with transaction.atomic(get_using(product)):
+        product.delete()
+
+
+@pytest.mark.django_db(transaction=True)
+def test_exception_on_save(settings, picture, caplog):
+    settings.DEFAULT_FILE_STORAGE = 'test.storage.DeleteErrorStorage'
+    product = Product.objects.create(image=picture['filename'])
+    # simulate a fieldfile that has a storage that raises a filenotfounderror on delete
+    assert os.path.exists(picture['path'])
+    product.image.delete(save=False)
+    product.image = None
+    assert not os.path.exists(picture['path'])
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+
+    for record in caplog.records:
+        assert LINE.sub('line xxx', record.exc_text) == getTraceback().format(picture=picture['path'])
+    assert caplog.record_tuples == [
+        (
+            'django_cleanup.handlers',
+            logging.ERROR,
+            'There was an exception deleting the file `{}` on field `test.product.image`'.format(
+                picture['filename'])
+        )
+    ]
+
+
+@pytest.mark.django_db(transaction=True)
+def test_cascade_delete(picture):
+    root = RootProduct.objects.create()
+    branch = BranchProduct.objects.create(root=root, image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    root = RootProduct.objects.get()
+    with transaction.atomic(get_using(root)):
+        root.delete()
+    assert not os.path.exists(picture['path'])
+
+
+@pytest.mark.django_db(transaction=True)
+def test_file_exists_on_create_and_update():
+    # If a filepath is specified which already exists,
+    # the FileField generates a random suffix to choose a different location.
+    # We need to make sure, that we fetch this change and would delete the correct one
+    # on further edits or the final deletion.
+    # In this test case it is simulated by using a temporary file located
+    # directly within the same directory as the image would be uploaded to.
+
+    upload_to = Product._meta.get_field("image").upload_to
+    dst_directory = os.path.join(settings.MEDIA_ROOT, upload_to)
+    if not os.path.isdir(dst_directory):
+        os.makedirs(dst_directory)
+
+    # create the new product with a file to simulate an "upload"
+    # a file aleady exists so the new file is renamed then saved
+    with tempfile.NamedTemporaryFile(prefix="f1__", dir=dst_directory) as f1:
+        with transaction.atomic():
+            product = Product.objects.create(image=File(f1, name=os.path.join(upload_to, os.path.basename(f1.name))))
+
+        assert f1.name != product.image.path
+        assert os.path.exists(f1.name)
+        assert os.path.exists(product.image.path)
+
+        path_prior_to_edit = product.image.path
+
+        # edit the product to change the product file to a different file
+        # check that it deletes the renamed file, not the original existing file
+        with tempfile.NamedTemporaryFile(prefix="f2__", dir=dst_directory) as f2:
+            with transaction.atomic(get_using(product)):
+                product.image = File(f2, name=os.path.join(upload_to, os.path.basename(f2.name)))
+                assert f2.name == product.image.path
+                product.save()
+
+            assert f1.name != product.image.path
+            assert os.path.exists(f1.name)
+            assert f2.name != product.image.path
+            assert os.path.isfile(f2.name)
+            assert os.path.isfile(product.image.path)
+            assert not os.path.isfile(path_prior_to_edit)
+
+            with transaction.atomic(get_using(product)):
+                product.delete()
+
+            assert os.path.isfile(f1.name)
+            assert os.path.isfile(f2.name)
+            assert not os.path.isfile(product.image.path)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_signals(picture):
+    prekwargs = None
+    postkwargs = None
+    def assn_prekwargs(**kwargs):
+        nonlocal prekwargs
+        prekwargs = kwargs
+
+    def assn_postkwargs(**kwargs):
+        nonlocal postkwargs
+        postkwargs = kwargs
+    
+    cleanup_pre_delete.connect(assn_prekwargs, dispatch_uid='pre_test_replace_file_with_file_signals')
+    cleanup_post_delete.connect(assn_postkwargs, dispatch_uid='post_test_replace_file_with_file_signals')
+    product = Product.objects.create(image=picture['filename'])
+    random_pic_name = get_random_pic_name()
+    product.image = random_pic_name
+    with transaction.atomic(get_using(product)):
+        product.save()
+
+    assert prekwargs['deleted'] == False
+    assert prekwargs['updated'] == True
+    assert prekwargs['instance'] == product
+    assert prekwargs['file'] is not None
+    assert prekwargs['file_name'] == picture['filename']
+    assert isinstance(prekwargs['default_file_name'], NOT_PROVIDED) 
+    assert prekwargs['model_name'] == 'test.product'
+    assert prekwargs['field_name'] == 'image'
+    
+    assert postkwargs['deleted'] == False
+    assert postkwargs['updated'] == True
+    assert postkwargs['instance'] == product
+    assert postkwargs['file'] is not None
+    assert postkwargs['file_name'] == picture['filename']
+    assert isinstance(postkwargs['default_file_name'], NOT_PROVIDED) 
+    assert postkwargs['model_name'] == 'test.product'
+    assert postkwargs['field_name'] == 'image'
+    assert postkwargs['success'] == True
+    assert postkwargs['error'] is None
+
+    with transaction.atomic(get_using(product)):
+        product.delete()
+
+    assert prekwargs['deleted'] == True
+    assert prekwargs['updated'] == False
+    assert prekwargs['instance'] == product
+    assert prekwargs['file'] is not None
+    assert prekwargs['file_name'] == random_pic_name
+    assert isinstance(prekwargs['default_file_name'], NOT_PROVIDED) 
+    assert prekwargs['model_name'] == 'test.product'
+    assert prekwargs['field_name'] == 'image'
+    
+    assert postkwargs['deleted'] == True
+    assert postkwargs['updated'] == False
+    assert postkwargs['instance'] == product
+    assert postkwargs['file'] is not None
+    assert postkwargs['file_name'] == random_pic_name
+    assert isinstance(postkwargs['default_file_name'], NOT_PROVIDED) 
+    assert postkwargs['model_name'] == 'test.product'
+    assert postkwargs['field_name'] == 'image'
+    assert postkwargs['success'] == True
+    assert postkwargs['error'] is None
+
+    cleanup_pre_delete.disconnect(None, dispatch_uid='pre_test_replace_file_with_file_signals')
+    cleanup_post_delete.disconnect(None, dispatch_uid='post_test_replace_file_with_file_signals')
+
+
+#region select config
+@pytest.mark.CleanupSelectedConfig
+@pytest.mark.django_db(transaction=True)
+def test__select_config__replace_file_with_file(picture):
+    product = Product.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    random_pic_name = get_random_pic_name()
+    product.image = random_pic_name
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+    assert product.image
+    new_image_path = os.path.join(settings.MEDIA_ROOT, random_pic_name)
+    assert product.image.path == new_image_path
+
+
+@pytest.mark.CleanupSelectedConfig
+@pytest.mark.django_db(transaction=True)
+def test__select_config__replace_file_with_file_ignore(picture):
+    product = ProductIgnore.objects.create(image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    random_pic_name = get_random_pic_name()
+    product.image = random_pic_name
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert os.path.exists(picture['path'])
+    assert product.image
+    new_image_path = os.path.join(settings.MEDIA_ROOT, random_pic_name)
+    assert product.image.path == new_image_path
+#endregion
diff --git a/test/test_integration.py b/test/test_integration.py
new file mode 100644
index 0000000..f693a4f
--- /dev/null
+++ b/test/test_integration.py
@@ -0,0 +1,90 @@
+import os
+
+from django.conf import settings
+from django.db import transaction
+
+import pytest
+
+from django_cleanup.signals import cleanup_pre_delete
+
+from .testing_helpers import get_using
+
+
+@pytest.mark.django_db(transaction=True)
+def test_sorlthumbnail_replace(settings, picture):
+    # https://github.com/mariocesar/sorl-thumbnail
+    models = pytest.importorskip("test.models.integration")
+    ProductIntegration = models.ProductIntegration
+    sorl_delete = models.sorl_delete
+    cleanup_pre_delete.connect(sorl_delete)
+    from sorl.thumbnail import get_thumbnail
+    product = ProductIntegration.objects.create(sorl_image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    im = get_thumbnail(
+        product.sorl_image, '100x100', crop='center', quality=50)
+    thumbnail_path = os.path.join(settings.MEDIA_ROOT, im.name)
+    assert os.path.exists(thumbnail_path)
+    product.sorl_image = 'new.png'
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+    assert not os.path.exists(thumbnail_path)
+    cleanup_pre_delete.disconnect(sorl_delete)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_sorlthumbnail_delete(picture):
+    # https://github.com/mariocesar/sorl-thumbnail
+    models = pytest.importorskip("test.models.integration")
+    ProductIntegration = models.ProductIntegration
+    sorl_delete = models.sorl_delete
+    cleanup_pre_delete.connect(sorl_delete)
+    from sorl.thumbnail import get_thumbnail
+    product = ProductIntegration.objects.create(sorl_image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    im = get_thumbnail(
+        product.sorl_image, '100x100', crop='center', quality=50)
+    thumbnail_path = os.path.join(settings.MEDIA_ROOT, im.name)
+    assert os.path.exists(thumbnail_path)
+    with transaction.atomic(get_using(product)):
+        product.delete()
+    assert not os.path.exists(picture['path'])
+    assert not os.path.exists(thumbnail_path)
+    cleanup_pre_delete.disconnect(sorl_delete)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_easythumbnails_replace(picture):
+    # https://github.com/SmileyChris/easy-thumbnails
+    models = pytest.importorskip("test.models.integration")
+    ProductIntegration = models.ProductIntegration
+    from easy_thumbnails.files import get_thumbnailer
+    product = ProductIntegration.objects.create(easy_image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    im = get_thumbnailer(product.easy_image).get_thumbnail(
+        {'size': (100, 100)})
+    thumbnail_path = os.path.join(settings.MEDIA_ROOT, im.name)
+    assert os.path.exists(thumbnail_path)
+    product.easy_image = 'new.png'
+    with transaction.atomic(get_using(product)):
+        product.save()
+    assert not os.path.exists(picture['path'])
+    assert not os.path.exists(thumbnail_path)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_easythumbnails_delete(picture):
+    # https://github.com/SmileyChris/easy-thumbnails
+    models = pytest.importorskip("test.models.integration")
+    ProductIntegration = models.ProductIntegration
+    from easy_thumbnails.files import get_thumbnailer
+    product = ProductIntegration.objects.create(easy_image=picture['filename'])
+    assert os.path.exists(picture['path'])
+    im = get_thumbnailer(product.easy_image).get_thumbnail(
+        {'size': (100, 100)})
+    thumbnail_path = os.path.join(settings.MEDIA_ROOT, im.name)
+    assert os.path.exists(thumbnail_path)
+    with transaction.atomic(get_using(product)):
+        product.delete()
+    assert not os.path.exists(picture['path'])
+    assert not os.path.exists(thumbnail_path)
diff --git a/test/testing_helpers.py b/test/testing_helpers.py
new file mode 100644
index 0000000..e66cfc8
--- /dev/null
+++ b/test/testing_helpers.py
@@ -0,0 +1,13 @@
+import random
+import string
+
+from django.db import router
+
+
+def get_using(instance):
+    return router.db_for_write(instance.__class__, instance=instance)
+
+
+def get_random_pic_name(length=20):
+    return 'pic{}.jpg'.format(
+        ''.join(random.choice(string.ascii_letters) for m in range(length)))

More details

Full run details