diff --git a/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py b/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py index 4d282d524e..b1f966b032 100644 --- a/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py +++ b/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py @@ -1,11 +1,11 @@ +import itertools + from django.apps import apps from django.contrib.contenttypes.models import ContentType from django.core.management import BaseCommand from django.db import DEFAULT_DB_ALIAS, router from django.db.models.deletion import Collector -from ...management import get_contenttypes_and_models - class Command(BaseCommand): @@ -18,18 +18,32 @@ class Command(BaseCommand): '--database', default=DEFAULT_DB_ALIAS, help='Nominates the database to use. Defaults to the "default" database.', ) + parser.add_argument( + '--include-stale-apps', action='store_true', default=False, + help=( + "Deletes stale content types including ones from previously " + "installed apps that have been removed from INSTALLED_APPS." + ), + ) def handle(self, **options): db = options['database'] + include_stale_apps = options['include_stale_apps'] interactive = options['interactive'] verbosity = options['verbosity'] - for app_config in apps.get_app_configs(): - content_types, app_models = get_contenttypes_and_models(app_config, db, ContentType) - to_remove = [ - ct for (model_name, ct) in content_types.items() - if model_name not in app_models - ] + if not router.allow_migrate_model(db, ContentType): + return + ContentType.objects.clear_cache() + + apps_content_types = itertools.groupby( + ContentType.objects.using(db).order_by('app_label', 'model'), + lambda obj: obj.app_label, + ) + for app_label, content_types in apps_content_types: + if not include_stale_apps and app_label not in apps.app_configs: + continue + to_remove = [ct for ct in content_types if ct.model_class() is None] # Confirm that the content type is stale before deletion. using = router.db_for_write(ContentType) if to_remove: diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index a5ef671e26..56358b4ef8 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -1651,6 +1651,13 @@ the deletion. Specifies the database to use. Defaults to ``default``. +.. django-admin-option:: --include-stale-apps + +.. versionadded:: 3.1 + +Deletes stale content types including ones from previously installed apps that +have been removed from :setting:`INSTALLED_APPS`. Defaults to ``False``. + ``django.contrib.gis`` ---------------------- diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index efc57e8d01..77a71bbc43 100644 --- a/docs/releases/3.1.txt +++ b/docs/releases/3.1.txt @@ -99,7 +99,9 @@ Minor features :mod:`django.contrib.contenttypes` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* The new :option:`remove_stale_contenttypes --include-stale-apps` option + allows removing stale content types from previously installed apps that have + been removed from :setting:`INSTALLED_APPS`. :mod:`django.contrib.gis` ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/contenttypes_tests/test_management.py b/tests/contenttypes_tests/test_management.py index 3e375518c9..65842aa09e 100644 --- a/tests/contenttypes_tests/test_management.py +++ b/tests/contenttypes_tests/test_management.py @@ -10,10 +10,15 @@ from django.test.utils import captured_stdout from .models import ModelWithNullFKToSite, Post -@modify_settings(INSTALLED_APPS={'append': ['no_models']}) +@modify_settings(INSTALLED_APPS={'append': ['empty_models', 'no_models']}) class RemoveStaleContentTypesTests(TestCase): # Speed up tests by avoiding retrieving ContentTypes for all test apps. - available_apps = ['contenttypes_tests', 'no_models', 'django.contrib.contenttypes'] + available_apps = [ + 'contenttypes_tests', + 'empty_models', + 'no_models', + 'django.contrib.contenttypes', + ] def setUp(self): self.before_count = ContentType.objects.count() @@ -65,9 +70,34 @@ class RemoveStaleContentTypesTests(TestCase): contenttypes_management.create_contenttypes(self.app_config, interactive=False, verbosity=0, apps=apps) self.assertEqual(ContentType.objects.count(), self.before_count + 1) - def test_contenttypes_removed_in_apps_without_models(self): - ContentType.objects.create(app_label='no_models', model='Fake') + @modify_settings(INSTALLED_APPS={'remove': ['empty_models']}) + def test_contenttypes_removed_in_installed_apps_without_models(self): + ContentType.objects.create(app_label='empty_models', model='Fake 1') + ContentType.objects.create(app_label='no_models', model='Fake 2') with mock.patch('builtins.input', return_value='yes'), captured_stdout() as stdout: call_command('remove_stale_contenttypes', verbosity=2) - self.assertIn("Deleting stale content type 'no_models | Fake'", stdout.getvalue()) + self.assertNotIn( + "Deleting stale content type 'empty_models | Fake 1'", + stdout.getvalue(), + ) + self.assertIn( + "Deleting stale content type 'no_models | Fake 2'", + stdout.getvalue(), + ) + self.assertEqual(ContentType.objects.count(), self.before_count + 1) + + @modify_settings(INSTALLED_APPS={'remove': ['empty_models']}) + def test_contenttypes_removed_for_apps_not_in_installed_apps(self): + ContentType.objects.create(app_label='empty_models', model='Fake 1') + ContentType.objects.create(app_label='no_models', model='Fake 2') + with mock.patch('builtins.input', return_value='yes'), captured_stdout() as stdout: + call_command('remove_stale_contenttypes', include_stale_apps=True, verbosity=2) + self.assertIn( + "Deleting stale content type 'empty_models | Fake 1'", + stdout.getvalue(), + ) + self.assertIn( + "Deleting stale content type 'no_models | Fake 2'", + stdout.getvalue(), + ) self.assertEqual(ContentType.objects.count(), self.before_count)