Commit 8ac20c79 authored by webchick's avatar webchick

Issue #2688945 by tduong, amateescu, timmillwood, alexpott, joachim, xjm,...

Issue #2688945 by tduong, amateescu, timmillwood, alexpott, joachim, xjm, yoroy, ifrik, Berdir, Gábor Hojtsy, Bojhan, webchick, cilefen, dawehner: Allow removing a module's content entities prior to module uninstallation
parent a1d45d3c
......@@ -5,6 +5,7 @@
use Drupal\Core\Extension\ModuleUninstallValidatorInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\Core\Url;
/**
* Validates module uninstall readiness based on existing content entities.
......@@ -37,10 +38,14 @@ public function __construct(EntityManagerInterface $entity_manager, TranslationI
*/
public function validate($module) {
$entity_types = $this->entityManager->getDefinitions();
$reasons = array();
$reasons = [];
foreach ($entity_types as $entity_type) {
if ($module == $entity_type->getProvider() && $entity_type instanceof ContentEntityTypeInterface && $this->entityManager->getStorage($entity_type->id())->hasData()) {
$reasons[] = $this->t('There is content for the entity type: @entity_type', array('@entity_type' => $entity_type->getLabel()));
$reasons[] = $this->t('There is content for the entity type: @entity_type. <a href=":url">Remove @entity_type_plural</a>.', [
'@entity_type' => $entity_type->getLabel(),
'@entity_type_plural' => $entity_type->getPluralLabel(),
':url' => Url::fromRoute('system.prepare_modules_entity_uninstall', ['entity_type_id' => $entity_type->id()])->toString(),
]);
}
}
return $reasons;
......
<?php
namespace Drupal\system\Form;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Provides a form removing module content entities data before uninstallation.
*/
class PrepareModulesEntityUninstallForm extends ConfirmFormBase {
/**
* The entity type ID of the entities to delete.
*
* @var string
*/
protected $entityTypeId;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* Constructs a PrepareModulesEntityUninstallForm object.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'system_prepare_modules_entity_uninstall';
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
$entity_type = $this->entityTypeManager->getDefinition($this->entityTypeId);
return $this->t('Are you sure you want to delete all @entity_type_plural?', ['@entity_type_plural' => $entity_type->getPluralLabel()]);
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('This action cannot be undone.<br />Make a backup of your database if you want to be able to restore these items.');
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
$entity_type = $this->entityTypeManager->getDefinition($this->entityTypeId);
return $this->t('Delete all @entity_type_plural', ['@entity_type_plural' => $entity_type->getPluralLabel()]);
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return Url::fromRoute('system.modules_uninstall');
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, $entity_type_id = NULL) {
$this->entityTypeId = $entity_type_id;
if (!$this->entityTypeManager->hasDefinition($this->entityTypeId)) {
throw new NotFoundHttpException();
}
$form = parent::buildForm($form, $form_state);
$storage = $this->entityTypeManager->getStorage($entity_type_id);
$count = $storage->getQuery()->count()->execute();
$form['entity_type_id'] = [
'#type' => 'value',
'#value' => $entity_type_id,
];
// Display a list of the 10 entity labels, if possible.
$entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
if ($count == 0) {
$form['total'] = [
'#markup' => $this->t(
'There are 0 @entity_type_plural to delete.',
['@entity_type_plural' => $entity_type->getPluralLabel()]
),
];
}
elseif ($entity_type->hasKey('label')) {
$recent_entity_ids = $storage->getQuery()
->sort($entity_type->getKey('id'), 'DESC')
->pager(10)
->execute();
$recent_entities = $storage->loadMultiple($recent_entity_ids);
$labels = [];
foreach ($recent_entities as $entity) {
$labels[] = $entity->label();
}
if ($labels) {
$form['recent_entity_labels'] = [
'#theme' => 'item_list',
'#items' => $labels,
];
$more_count = $count - count($labels);
$form['total'] = [
'#markup' => $this->formatPlural(
$more_count,
'And <strong>@count</strong> more @entity_type_singular.',
'And <strong>@count</strong> more @entity_type_plural.',
[
'@entity_type_singular' => $entity_type->getSingularLabel(),
'@entity_type_plural' => $entity_type->getPluralLabel(),
]
),
'#access' => (bool) $more_count,
];
}
}
else {
$form['total'] = [
'#markup' => $this->formatPlural(
$count,
'This will delete <strong>@count</strong> @entity_type_singular.',
'This will delete <strong>@count</strong> @entity_type_plural.',
[
'@entity_type_singular' => $entity_type->getSingularLabel(),
'@entity_type_plural' => $entity_type->getPluralLabel(),
]
)
];
}
$form['description']['#prefix'] = '<p>';
$form['description']['#suffix'] = '</p>';
$form['description']['#weight'] = 5;
// Only show the delete button if there are entities to delete.
$form['actions']['submit']['#access'] = (bool) $count;
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$entity_type_id = $form_state->getValue('entity_type_id');
$entity_type_plural = $this->entityTypeManager->getDefinition($entity_type_id)->getPluralLabel();
$batch = [
'title' => t('Deleting @entity_type_plural', [
'@entity_type_plural' => $entity_type_plural,
]),
'operations' => [
[
[__CLASS__, 'deleteContentEntities'], [$entity_type_id],
],
],
'finished' => [__CLASS__, 'moduleBatchFinished'],
'progress_message' => '',
];
batch_set($batch);
}
/**
* Deletes the content entities of the specified entity type.
*
* @param string $entity_type_id
* The entity type ID from which data will be deleted.
* @param array|\ArrayAccess $context
* The batch context array, passed by reference.
*
* @internal
* This batch callback is only meant to be used by this form.
*/
public static function deleteContentEntities($entity_type_id, &$context) {
$storage = \Drupal::entityTypeManager()->getStorage($entity_type_id);
// Set the entity type ID in the results array so we can access it in the
// batch finished callback.
$context['results']['entity_type_id'] = $entity_type_id;
if (!isset($context['sandbox']['progress'])) {
$context['sandbox']['progress'] = 0;
$context['sandbox']['max'] = $storage->getQuery()->count()->execute();
}
$entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id);
$entity_ids = $storage->getQuery()
->sort($entity_type->getKey('id'), 'ASC')
->range(0, 10)
->execute();
if ($entities = $storage->loadMultiple($entity_ids)) {
$storage->delete($entities);
}
// Sometimes deletes cause secondary deletes. For example, deleting a
// taxonomy term can cause it's children to be be deleted too.
$context['sandbox']['progress'] = $context['sandbox']['max'] - $storage->getQuery()->count()->execute();
// Inform the batch engine that we are not finished and provide an
// estimation of the completion level we reached.
if (count($entity_ids) > 0 && $context['sandbox']['progress'] != $context['sandbox']['max']) {
$context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
$context['message'] = t('Deleting items... Completed @percentage% (@current of @total).', ['@percentage' => round(100 * $context['sandbox']['progress'] / $context['sandbox']['max']), '@current' => $context['sandbox']['progress'], '@total' => $context['sandbox']['max']]);
}
else {
$context['finished'] = 1;
}
}
/**
* Implements callback_batch_finished().
*
* Finishes the module batch, redirect to the uninstall page and output the
* successful data deletion message.
*/
public static function moduleBatchFinished($success, $results, $operations) {
$entity_type_plural = \Drupal::entityTypeManager()->getDefinition($results['entity_type_id'])->getPluralLabel();
drupal_set_message(t('All @entity_type_plural have been deleted.', ['@entity_type_plural' => $entity_type_plural]));
return new RedirectResponse(Url::fromRoute('system.modules_uninstall')->setAbsolute()->toString());
}
}
<?php
namespace Drupal\system\Tests\Module;
use Drupal\Component\Utility\Unicode;
use Drupal\simpletest\WebTestBase;
use Drupal\taxonomy\Tests\TaxonomyTestTrait;
/**
* Tests that modules which provide entity types can be uninstalled.
*
* @group Module
*/
class PrepareUninstallTest extends WebTestBase {
use TaxonomyTestTrait;
/**
* An array of node objects.
*
* @var \Drupal\node\NodeInterface[]
*/
protected $nodes;
/**
* An array of taxonomy term objects.
*
* @var \Drupal\taxonomy\TermInterface[]
*/
protected $terms;
/**
* Modules to enable.
*
* @var array
*/
public static $modules = ['node', 'taxonomy', 'entity_test'];
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
$admin_user = $this->drupalCreateUser(['administer modules']);
$this->drupalLogin($admin_user);
// Create 10 nodes.
for ($i = 1; $i <= 5; $i++) {
$this->nodes[] = $this->drupalCreateNode(['type' => 'page']);
$this->nodes[] = $this->drupalCreateNode(['type' => 'article']);
}
// Create 3 top-level taxonomy terms, each with 11 children.
$vocabulary = $this->createVocabulary();
for ($i = 1; $i <= 3; $i++) {
$term = $this->createTerm($vocabulary);
$this->terms[] = $term;
for ($j = 1; $j <= 11; $j++) {
$this->terms[] = $this->createTerm($vocabulary, ['parent' => ['target_id' => $term->id()]]);
}
}
}
/**
* Tests that Node and Taxonomy can be uninstalled.
*/
public function testUninstall() {
// Check that Taxonomy cannot be uninstalled yet.
$this->drupalGet('admin/modules/uninstall');
$this->assertText('Remove content items');
$this->assertLinkByHref('admin/modules/uninstall/entity/taxonomy_term');
// Delete Taxonomy term data.
$this->drupalGet('admin/modules/uninstall/entity/taxonomy_term');
$term_count = count($this->terms);
for ($i = 1; $i < 11; $i++) {
$this->assertText($this->terms[$term_count - $i]->label());
}
$term_count = $term_count - 10;
$this->assertText("And $term_count more taxonomy term entities.");
$this->assertText('This action cannot be undone.');
$this->assertText('Make a backup of your database if you want to be able to restore these items.');
$this->drupalPostForm(NULL, [], t('Delete all taxonomy term entities'));
// Check that we are redirected to the uninstall page and data has been
// removed.
$this->assertUrl('admin/modules/uninstall', []);
$this->assertText('All taxonomy term entities have been deleted.');
// Check that there is no more data to be deleted, Taxonomy is ready to be
// uninstalled.
$this->assertText('Enables the categorization of content.');
$this->assertNoLinkByHref('admin/modules/uninstall/entity/taxonomy_term');
// Uninstall the Taxonomy module.
$this->drupalPostForm('admin/modules/uninstall', ['uninstall[taxonomy]' => TRUE], t('Uninstall'));
$this->drupalPostForm(NULL, [], t('Uninstall'));
$this->assertText('The selected modules have been uninstalled.');
$this->assertNoText('Enables the categorization of content.');
// Check Node cannot be uninstalled yet, there is content to be removed.
$this->drupalGet('admin/modules/uninstall');
$this->assertText('Remove content items');
$this->assertLinkByHref('admin/modules/uninstall/entity/node');
// Delete Node data.
$this->drupalGet('admin/modules/uninstall/entity/node');
// All 10 nodes should be listed.
foreach ($this->nodes as $node) {
$this->assertText($node->label());
}
// Ensures there is no more count when not necessary.
$this->assertNoText('And 0 more content');
$this->assertText('This action cannot be undone.');
$this->assertText('Make a backup of your database if you want to be able to restore these items.');
// Create another node so we have 11.
$this->nodes[] = $this->drupalCreateNode(['type' => 'page']);
$this->drupalGet('admin/modules/uninstall/entity/node');
// Ensures singular case is used when a single entity is left after listing
// the first 10's labels.
$this->assertText('And 1 more content item.');
// Create another node so we have 12.
$this->nodes[] = $this->drupalCreateNode(['type' => 'article']);
$this->drupalGet('admin/modules/uninstall/entity/node');
// Ensures singular case is used when a single entity is left after listing
// the first 10's labels.
$this->assertText('And 2 more content items.');
$this->drupalPostForm(NULL, [], t('Delete all content items'));
// Check we are redirected to the uninstall page and data has been removed.
$this->assertUrl('admin/modules/uninstall', []);
$this->assertText('All content items have been deleted.');
// Check there is no more data to be deleted, Node is ready to be
// uninstalled.
$this->assertText('Allows content to be submitted to the site and displayed on pages.');
$this->assertNoLinkByHref('admin/modules/uninstall/entity/node');
// Uninstall Node module.
$this->drupalPostForm('admin/modules/uninstall', ['uninstall[node]' => TRUE], t('Uninstall'));
$this->drupalPostForm(NULL, [], t('Uninstall'));
$this->assertText('The selected modules have been uninstalled.');
$this->assertNoText('Allows content to be submitted to the site and displayed on pages.');
// Ensure the proper response when accessing a non-existent entity type.
$this->drupalGet('admin/modules/uninstall/entity/node');
$this->assertResponse(404, 'Entity types that do not exist result in a 404.');
// Test an entity type which does not have any existing entities.
$this->drupalGet('admin/modules/uninstall/entity/entity_test_no_label');
$this->assertText('There are 0 entity test without label entities to delete.');
$button_xpath = '//input[@type="submit"][@value="Delete all entity test without label entities"]';
$this->assertNoFieldByXPath($button_xpath, NULL, 'Button with value "Delete all entity test without label entities" not found');
// Test an entity type without a label.
/** @var \Drupal\Core\Entity\EntityStorageInterface $storage */
$storage = $this->container->get('entity.manager')
->getStorage('entity_test_no_label');
$storage->create([
'id' => Unicode::strtolower($this->randomMachineName()),
'name' => $this->randomMachineName(),
])->save();
$this->drupalGet('admin/modules/uninstall/entity/entity_test_no_label');
$this->assertText('This will delete 1 entity test without label.');
$this->assertFieldByXPath($button_xpath, NULL, 'Button with value "Delete all entity test without label entities" found');
$storage->create([
'id' => Unicode::strtolower($this->randomMachineName()),
'name' => $this->randomMachineName(),
])->save();
$this->drupalGet('admin/modules/uninstall/entity/entity_test_no_label');
$this->assertText('This will delete 2 entity test without label entities.');
}
}
......@@ -423,6 +423,14 @@ system.modules_uninstall_confirm:
requirements:
_permission: 'administer modules'
system.prepare_modules_entity_uninstall:
path: '/admin/modules/uninstall/entity/{entity_type_id}'
defaults:
_form: '\Drupal\system\Form\PrepareModulesEntityUninstallForm'
_title_callback: '\Drupal\system\Form\PrepareModulesEntityUninstallForm::formTitle'
requirements:
_permission: 'administer modules'
system.timezone:
path: '/system/timezone/{abbreviation}/{offset}/{is_daylight_saving_time}'
defaults:
......
......@@ -3,7 +3,6 @@
namespace Drupal\Tests\system\Kernel\Extension;
use Drupal\Core\Extension\MissingDependencyException;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use \Drupal\Core\Extension\ModuleUninstallValidatorException;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\KernelTests\KernelTestBase;
......@@ -36,16 +35,6 @@ protected function setUp() {
system_rebuild_module_data();
}
/**
* {@inheritdoc}
*/
public function register(ContainerBuilder $container) {
parent::register($container);
// Put a fake route bumper on the container to be called during uninstall.
$container
->register('router.dumper', 'Drupal\Core\Routing\NullMatcherDumper');
}
/**
* The basic functionality of retrieving enabled modules.
*/
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment