mirror of https://github.com/django/django.git
Fixed #24865 -- Added remove_stale_contenttypes management command.
Thanks Simon Charette for the review.
This commit is contained in:
parent
4c94336510
commit
6a2af01452
|
@ -5,7 +5,7 @@ from django.db.models.signals import post_migrate, pre_migrate
|
|||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from .management import (
|
||||
inject_rename_contenttypes_operations, update_contenttypes,
|
||||
create_contenttypes, inject_rename_contenttypes_operations,
|
||||
)
|
||||
|
||||
|
||||
|
@ -15,5 +15,5 @@ class ContentTypesConfig(AppConfig):
|
|||
|
||||
def ready(self):
|
||||
pre_migrate.connect(inject_rename_contenttypes_operations, sender=self)
|
||||
post_migrate.connect(update_contenttypes)
|
||||
post_migrate.connect(create_contenttypes)
|
||||
checks.register(check_generic_foreign_keys, checks.Tags.models)
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
from django.apps import apps as global_apps
|
||||
from django.db import DEFAULT_DB_ALIAS, migrations, router, transaction
|
||||
from django.db.models.deletion import Collector
|
||||
from django.db.utils import IntegrityError
|
||||
from django.utils import six
|
||||
from django.utils.six.moves import input
|
||||
|
||||
|
||||
class RenameContentType(migrations.RunPython):
|
||||
|
@ -30,8 +28,8 @@ class RenameContentType(migrations.RunPython):
|
|||
content_type.save(update_fields={'model'})
|
||||
except IntegrityError:
|
||||
# Gracefully fallback if a stale content type causes a
|
||||
# conflict as update_contenttypes will take care of asking the
|
||||
# user what should be done next.
|
||||
# conflict as remove_stale_contenttypes will take care of
|
||||
# asking the user what should be done next.
|
||||
content_type.model = old_model
|
||||
else:
|
||||
# Clear the cache as the `get_by_natual_key()` call will cache
|
||||
|
@ -87,10 +85,26 @@ def inject_rename_contenttypes_operations(plan=None, apps=global_apps, using=DEF
|
|||
migration.operations.insert(inserted + index, operation)
|
||||
|
||||
|
||||
def update_contenttypes(app_config, verbosity=2, interactive=True, using=DEFAULT_DB_ALIAS, apps=global_apps, **kwargs):
|
||||
def get_contenttypes_and_models(app_config, using, ContentType):
|
||||
if not router.allow_migrate_model(using, ContentType):
|
||||
return None, None
|
||||
|
||||
ContentType.objects.clear_cache()
|
||||
|
||||
content_types = {
|
||||
ct.model: ct
|
||||
for ct in ContentType.objects.using(using).filter(app_label=app_config.label)
|
||||
}
|
||||
app_models = {
|
||||
model._meta.model_name: model
|
||||
for model in app_config.get_models()
|
||||
}
|
||||
return content_types, app_models
|
||||
|
||||
|
||||
def create_contenttypes(app_config, verbosity=2, interactive=True, using=DEFAULT_DB_ALIAS, apps=global_apps, **kwargs):
|
||||
"""
|
||||
Creates content types for models in the given app, removing any model
|
||||
entries that no longer have a matching model class.
|
||||
Creates content types for models in the given app.
|
||||
"""
|
||||
if not app_config.models_module:
|
||||
return
|
||||
|
@ -102,32 +116,11 @@ def update_contenttypes(app_config, verbosity=2, interactive=True, using=DEFAULT
|
|||
except LookupError:
|
||||
return
|
||||
|
||||
if not router.allow_migrate_model(using, ContentType):
|
||||
return
|
||||
|
||||
ContentType.objects.clear_cache()
|
||||
# Always clear the global content types cache.
|
||||
if apps is not global_apps:
|
||||
global_apps.get_model('contenttypes', 'ContentType').objects.clear_cache()
|
||||
|
||||
app_models = {
|
||||
model._meta.model_name: model
|
||||
for model in app_config.get_models()}
|
||||
content_types, app_models = get_contenttypes_and_models(app_config, using, ContentType)
|
||||
|
||||
if not app_models:
|
||||
return
|
||||
|
||||
# Get all the content types
|
||||
content_types = {
|
||||
ct.model: ct
|
||||
for ct in ContentType.objects.using(using).filter(app_label=app_label)
|
||||
}
|
||||
to_remove = [
|
||||
ct
|
||||
for (model_name, ct) in six.iteritems(content_types)
|
||||
if model_name not in app_models
|
||||
]
|
||||
|
||||
cts = [
|
||||
ContentType(
|
||||
app_label=app_label,
|
||||
|
@ -140,55 +133,3 @@ def update_contenttypes(app_config, verbosity=2, interactive=True, using=DEFAULT
|
|||
if verbosity >= 2:
|
||||
for ct in cts:
|
||||
print("Adding content type '%s | %s'" % (ct.app_label, ct.model))
|
||||
|
||||
# Confirm that the content type is stale before deletion.
|
||||
using = router.db_for_write(ContentType)
|
||||
if to_remove:
|
||||
if interactive:
|
||||
ct_info = []
|
||||
for ct in to_remove:
|
||||
ct_info.append(' - Content type for %s.%s' % (ct.app_label, ct.model))
|
||||
collector = NoFastDeleteCollector(using=using)
|
||||
collector.collect([ct])
|
||||
|
||||
for obj_type, objs in collector.data.items():
|
||||
if objs == {ct}:
|
||||
continue
|
||||
ct_info.append(' - %s %s object(s)' % (
|
||||
len(objs),
|
||||
obj_type._meta.label,
|
||||
))
|
||||
|
||||
content_type_display = '\n'.join(ct_info)
|
||||
print("""Some content types in your database are stale and can be deleted.
|
||||
Any objects that depend on these content types will also be deleted.
|
||||
The content types and dependent objects that would be deleted are:
|
||||
|
||||
%s
|
||||
|
||||
This list doesn't include any cascade deletions to data outside of Django's
|
||||
models (uncommon).
|
||||
|
||||
Are you sure you want to delete these content types?
|
||||
If you're unsure, answer 'no'.
|
||||
""" % content_type_display)
|
||||
ok_to_delete = input("Type 'yes' to continue, or 'no' to cancel: ")
|
||||
else:
|
||||
ok_to_delete = False
|
||||
|
||||
if ok_to_delete == 'yes':
|
||||
for ct in to_remove:
|
||||
if verbosity >= 2:
|
||||
print("Deleting stale content type '%s | %s'" % (ct.app_label, ct.model))
|
||||
ct.delete()
|
||||
else:
|
||||
if verbosity >= 2:
|
||||
print("Stale content types remain.")
|
||||
|
||||
|
||||
class NoFastDeleteCollector(Collector):
|
||||
def can_fast_delete(self, *args, **kwargs):
|
||||
"""
|
||||
Always load related objects to display them when showing confirmation.
|
||||
"""
|
||||
return False
|
|
@ -0,0 +1,86 @@
|
|||
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 django.utils import six
|
||||
from django.utils.six.moves import input
|
||||
|
||||
from ...management import get_contenttypes_and_models
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--noinput', '--no-input',
|
||||
action='store_false', dest='interactive', default=True,
|
||||
help='Tells Django to NOT prompt the user for input of any kind.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--database', action='store', dest='database', default=DEFAULT_DB_ALIAS,
|
||||
help='Nominates the database to use. Defaults to the "default" database.',
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
db = options['database']
|
||||
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)
|
||||
if not app_models:
|
||||
continue
|
||||
to_remove = [
|
||||
ct for (model_name, ct) in six.iteritems(content_types)
|
||||
if model_name not in app_models
|
||||
]
|
||||
# Confirm that the content type is stale before deletion.
|
||||
using = router.db_for_write(ContentType)
|
||||
if to_remove:
|
||||
if interactive:
|
||||
ct_info = []
|
||||
for ct in to_remove:
|
||||
ct_info.append(' - Content type for %s.%s' % (ct.app_label, ct.model))
|
||||
collector = NoFastDeleteCollector(using=using)
|
||||
collector.collect([ct])
|
||||
|
||||
for obj_type, objs in collector.data.items():
|
||||
if objs == {ct}:
|
||||
continue
|
||||
ct_info.append(' - %s %s object(s)' % (
|
||||
len(objs),
|
||||
obj_type._meta.label,
|
||||
))
|
||||
content_type_display = '\n'.join(ct_info)
|
||||
self.stdout.write("""Some content types in your database are stale and can be deleted.
|
||||
Any objects that depend on these content types will also be deleted.
|
||||
The content types and dependent objects that would be deleted are:
|
||||
|
||||
%s
|
||||
|
||||
This list doesn't include any cascade deletions to data outside of Django's
|
||||
models (uncommon).
|
||||
|
||||
Are you sure you want to delete these content types?
|
||||
If you're unsure, answer 'no'.\n""" % content_type_display)
|
||||
ok_to_delete = input("Type 'yes' to continue, or 'no' to cancel: ")
|
||||
else:
|
||||
ok_to_delete = False
|
||||
|
||||
if ok_to_delete == 'yes':
|
||||
for ct in to_remove:
|
||||
if verbosity >= 2:
|
||||
self.stdout.write("Deleting stale content type '%s | %s'" % (ct.app_label, ct.model))
|
||||
ct.delete()
|
||||
else:
|
||||
if verbosity >= 2:
|
||||
self.stdout.write("Stale content types remain.")
|
||||
|
||||
|
||||
class NoFastDeleteCollector(Collector):
|
||||
def can_fast_delete(self, *args, **kwargs):
|
||||
"""
|
||||
Always load related objects to display them when showing confirmation.
|
||||
"""
|
||||
return False
|
|
@ -118,10 +118,7 @@ class ContentTypeManager(models.Manager):
|
|||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
Clear out the content-type cache. This needs to happen during database
|
||||
flushes to prevent caching of "stale" content type IDs (see
|
||||
django.contrib.contenttypes.management.update_contenttypes for where
|
||||
this gets called).
|
||||
Clear out the content-type cache.
|
||||
"""
|
||||
self._cache.clear()
|
||||
|
||||
|
|
|
@ -1456,6 +1456,28 @@ it could be useful if you have a ``ForeignKey`` in
|
|||
allow creating an instance instead of entering the primary key of an existing
|
||||
instance.
|
||||
|
||||
``django.contrib.contenttypes``
|
||||
-------------------------------
|
||||
|
||||
``remove_stale_contenttypes``
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. django-admin:: remove_stale_contenttypes
|
||||
|
||||
.. versionadded:: 1.11
|
||||
|
||||
This command is only available if Django's :doc:`contenttypes app
|
||||
</ref/contrib/contenttypes>` (:mod:`django.contrib.contenttypes`) is installed.
|
||||
|
||||
Deletes stale content types (from deleted models) in your database. Any objects
|
||||
that depend on the deleted content types will also be deleted. A list of
|
||||
deleted objects will be displayed before you confirm it's okay to proceed with
|
||||
the deletion.
|
||||
|
||||
.. django-admin-option:: --database DATABASE
|
||||
|
||||
Specifies the database to use. Defaults to ``default``.
|
||||
|
||||
``django.contrib.gis``
|
||||
----------------------
|
||||
|
||||
|
|
|
@ -104,9 +104,11 @@ Minor features
|
|||
:mod:`django.contrib.contenttypes`
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
* When stale content types are detected after the ``migrate`` command, there's
|
||||
now a list of related objects such as ``auth.Permission``\s that will also be
|
||||
deleted. Previously, only the content types were listed.
|
||||
* When stale content types are detected in the
|
||||
:djadmin:`remove_stale_contenttypes` command, there's now a list of related
|
||||
objects such as ``auth.Permission``\s that will also be deleted. Previously,
|
||||
only the content types were listed (and this prompt was after ``migrate``
|
||||
rather than in a separate command).
|
||||
|
||||
:mod:`django.contrib.gis`
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
@ -477,6 +479,10 @@ Miscellaneous
|
|||
be backwards-incompatible if you have some :ref:`template tags that aren't
|
||||
thread safe <template_tag_thread_safety>`.
|
||||
|
||||
* The prompt for stale content type deletion no longer occurs after running the
|
||||
``migrate`` command. Use the new :djadmin:`remove_stale_contenttypes` command
|
||||
instead.
|
||||
|
||||
.. _deprecated-features-1.11:
|
||||
|
||||
Features deprecated in 1.11
|
||||
|
|
|
@ -12,6 +12,7 @@ from django.contrib.contenttypes.fields import (
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import checks, management
|
||||
from django.core.management import call_command
|
||||
from django.db import connections, migrations, models
|
||||
from django.test import (
|
||||
SimpleTestCase, TestCase, TransactionTestCase, mock, override_settings,
|
||||
|
@ -388,16 +389,19 @@ class UpdateContentTypesTests(TestCase):
|
|||
|
||||
def test_interactive_true_with_dependent_objects(self):
|
||||
"""
|
||||
interactive mode of update_contenttypes() (the default) should delete
|
||||
stale contenttypes and warn of dependent objects.
|
||||
interactive mode of remove_stale_contenttypes (the default) should
|
||||
delete stale contenttypes and warn of dependent objects.
|
||||
"""
|
||||
post = Post.objects.create(title='post', content_type=self.content_type)
|
||||
# A related object is needed to show that a custom collector with
|
||||
# can_fast_delete=False is needed.
|
||||
ModelWithNullFKToSite.objects.create(post=post)
|
||||
contenttypes_management.input = lambda x: force_str("yes")
|
||||
with captured_stdout() as stdout:
|
||||
contenttypes_management.update_contenttypes(self.app_config)
|
||||
with mock.patch(
|
||||
'django.contrib.contenttypes.management.commands.remove_stale_contenttypes.input',
|
||||
return_value='yes'
|
||||
):
|
||||
with captured_stdout() as stdout:
|
||||
call_command('remove_stale_contenttypes', verbosity=2, stdout=stdout)
|
||||
self.assertEqual(Post.objects.count(), 0)
|
||||
output = stdout.getvalue()
|
||||
self.assertIn('- Content type for contenttypes_tests.Fake', output)
|
||||
|
@ -408,33 +412,35 @@ class UpdateContentTypesTests(TestCase):
|
|||
|
||||
def test_interactive_true_without_dependent_objects(self):
|
||||
"""
|
||||
interactive mode of update_contenttypes() (the default) should delete
|
||||
stale contenttypes even if there aren't any dependent objects.
|
||||
interactive mode of remove_stale_contenttypes (the default) should
|
||||
delete stale contenttypes even if there aren't any dependent objects.
|
||||
"""
|
||||
contenttypes_management.input = lambda x: force_str("yes")
|
||||
with captured_stdout() as stdout:
|
||||
contenttypes_management.update_contenttypes(self.app_config)
|
||||
with mock.patch(
|
||||
'django.contrib.contenttypes.management.commands.remove_stale_contenttypes.input',
|
||||
return_value='yes'
|
||||
):
|
||||
with captured_stdout() as stdout:
|
||||
call_command('remove_stale_contenttypes', verbosity=2)
|
||||
self.assertIn("Deleting stale content type", stdout.getvalue())
|
||||
self.assertEqual(ContentType.objects.count(), self.before_count)
|
||||
|
||||
def test_interactive_false(self):
|
||||
"""
|
||||
non-interactive mode of update_contenttypes() shouldn't delete stale
|
||||
content types.
|
||||
non-interactive mode of remove_stale_contenttypes shouldn't delete
|
||||
stale content types.
|
||||
"""
|
||||
with captured_stdout() as stdout:
|
||||
contenttypes_management.update_contenttypes(self.app_config, interactive=False)
|
||||
call_command('remove_stale_contenttypes', interactive=False, verbosity=2)
|
||||
self.assertIn("Stale content types remain.", stdout.getvalue())
|
||||
self.assertEqual(ContentType.objects.count(), self.before_count + 1)
|
||||
|
||||
def test_unavailable_content_type_model(self):
|
||||
"""
|
||||
#24075 - A ContentType shouldn't be created or deleted if the model
|
||||
isn't available.
|
||||
A ContentType shouldn't be created if the model isn't available.
|
||||
"""
|
||||
apps = Apps()
|
||||
with self.assertNumQueries(0):
|
||||
contenttypes_management.update_contenttypes(self.app_config, interactive=False, verbosity=0, apps=apps)
|
||||
contenttypes_management.create_contenttypes(self.app_config, interactive=False, verbosity=0, apps=apps)
|
||||
self.assertEqual(ContentType.objects.count(), self.before_count + 1)
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue