diff --git a/core/modules/rest/tests/src/Functional/EntityResource/XmlEntityNormalizationQuirksTrait.php b/core/modules/rest/tests/src/Functional/EntityResource/XmlEntityNormalizationQuirksTrait.php index 3064ea3a50f04fbdc436ada378b1cf7b52d44e12..a7e588ad356e29fad3802cb866950fc17024e256 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/XmlEntityNormalizationQuirksTrait.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/XmlEntityNormalizationQuirksTrait.php @@ -14,6 +14,7 @@ use Drupal\options\Plugin\Field\FieldType\ListIntegerItem; use Drupal\path\Plugin\Field\FieldType\PathItem; use Drupal\Tests\rest\Functional\XmlNormalizationQuirksTrait; +use Drupal\user\StatusItem; /** * Trait for EntityResourceTestBase subclasses testing $format='xml'. @@ -63,6 +64,9 @@ protected function applyXmlFieldDecodingQuirks(array $normalization) { for ($i = 0; $i < count($normalization[$field_name]); $i++) { switch ($field->getItemDefinition()->getClass()) { case BooleanItem::class: + case StatusItem::class: + // @todo Remove the StatusItem case in + // https://www.drupal.org/project/drupal/issues/2936864. $value = &$normalization[$field_name][$i]['value']; $value = $value === TRUE ? '1' : '0'; break; diff --git a/core/modules/system/tests/src/Functional/Entity/EntityReferenceSelection/EntityReferenceSelectionAccessTest.php b/core/modules/system/tests/src/Functional/Entity/EntityReferenceSelection/EntityReferenceSelectionAccessTest.php index aac7f39552162167bd0fe798e229dceeeef24839..c037d12e3f1eff61dd6976db9a72d9c953c7437a 100644 --- a/core/modules/system/tests/src/Functional/Entity/EntityReferenceSelection/EntityReferenceSelectionAccessTest.php +++ b/core/modules/system/tests/src/Functional/Entity/EntityReferenceSelection/EntityReferenceSelectionAccessTest.php @@ -8,6 +8,8 @@ use Drupal\comment\CommentInterface; use Drupal\KernelTests\KernelTestBase; use Drupal\node\Entity\Node; +use Drupal\taxonomy\Entity\Term; +use Drupal\taxonomy\Entity\Vocabulary; use Drupal\node\NodeInterface; use Drupal\Tests\node\Traits\ContentTypeCreationTrait; use Drupal\Tests\user\Traits\UserCreationTrait; @@ -30,7 +32,7 @@ class EntityReferenceSelectionAccessTest extends KernelTestBase { * * @var array */ - public static $modules = ['comment', 'field', 'node', 'system', 'text', 'user']; + public static $modules = ['comment', 'field', 'node', 'system', 'taxonomy', 'text', 'user']; /** * {@inheritdoc} @@ -43,9 +45,10 @@ protected function setUp() { $this->installEntitySchema('comment'); $this->installEntitySchema('node'); + $this->installEntitySchema('taxonomy_term'); $this->installEntitySchema('user'); - $this->installConfig(['comment', 'field', 'node', 'user']); + $this->installConfig(['comment', 'field', 'node', 'taxonomy', 'user']); // Create the anonymous and the admin users. $anonymous_user = User::create([ @@ -534,4 +537,148 @@ public function testCommentHandler() { $this->assertReferenceable($selection_options, $referenceable_tests, 'Comment handler (comment + node admin)'); } + /** + * Test the term-specific overrides of the selection handler. + */ + public function testTermHandler() { + // Create a 'Tags' vocabulary. + Vocabulary::create([ + 'name' => 'Tags', + 'description' => $this->randomMachineName(), + 'vid' => 'tags', + ])->save(); + + $selection_options = [ + 'target_type' => 'taxonomy_term', + 'handler' => 'default', + 'target_bundles' => NULL, + ]; + + // Build a set of test data. + $term_values = [ + 'published1' => [ + 'vid' => 'tags', + 'status' => 1, + 'name' => 'Term published1', + ], + 'published2' => [ + 'vid' => 'tags', + 'status' => 1, + 'name' => 'Term published2', + ], + 'unpublished' => [ + 'vid' => 'tags', + 'status' => 0, + 'name' => 'Term unpublished', + ], + 'published3' => [ + 'vid' => 'tags', + 'status' => 1, + 'name' => 'Term published3', + 'parent' => 'unpublished', + ], + 'published4' => [ + 'vid' => 'tags', + 'status' => 1, + 'name' => 'Term published4', + 'parent' => 'published3', + ], + ]; + + $terms = []; + $term_labels = []; + foreach ($term_values as $key => $values) { + $term = Term::create($values); + if (isset($values['parent'])) { + $term->parent->entity = $terms[$values['parent']]; + } + $term->save(); + $terms[$key] = $term; + $term_labels[$key] = Html::escape($term->label()); + } + + // Test as a non-admin. + $normal_user = $this->createUser(['access content']); + $this->setCurrentUser($normal_user); + $referenceable_tests = [ + [ + 'arguments' => [ + [NULL, 'CONTAINS'], + ], + 'result' => [ + 'tags' => [ + $terms['published1']->id() => $term_labels['published1'], + $terms['published2']->id() => $term_labels['published2'], + ], + ], + ], + [ + 'arguments' => [ + ['published1', 'CONTAINS'], + ['Published1', 'CONTAINS'], + ], + 'result' => [ + 'tags' => [ + $terms['published1']->id() => $term_labels['published1'], + ], + ], + ], + [ + 'arguments' => [ + ['published2', 'CONTAINS'], + ['Published2', 'CONTAINS'], + ], + 'result' => [ + 'tags' => [ + $terms['published2']->id() => $term_labels['published2'], + ], + ], + ], + [ + 'arguments' => [ + ['invalid term', 'CONTAINS'], + ], + 'result' => [], + ], + [ + 'arguments' => [ + ['Term unpublished', 'CONTAINS'], + ], + 'result' => [], + ], + ]; + $this->assertReferenceable($selection_options, $referenceable_tests, 'Term handler'); + + // Test as an admin. + $admin_user = $this->createUser(['access content', 'administer taxonomy']); + $this->setCurrentUser($admin_user); + $referenceable_tests = [ + [ + 'arguments' => [ + [NULL, 'CONTAINS'], + ], + 'result' => [ + 'tags' => [ + $terms['published1']->id() => $term_labels['published1'], + $terms['published2']->id() => $term_labels['published2'], + $terms['unpublished']->id() => $term_labels['unpublished'], + $terms['published3']->id() => '-' . $term_labels['published3'], + $terms['published4']->id() => '--' . $term_labels['published4'], + ], + ], + ], + [ + 'arguments' => [ + ['Term unpublished', 'CONTAINS'], + ], + 'result' => [ + 'tags' => [ + $terms['unpublished']->id() => $term_labels['unpublished'], + ], + ], + ], + ]; + $this->assertReferenceable($selection_options, $referenceable_tests, 'Term handler (admin)'); + } + } diff --git a/core/modules/taxonomy/src/Entity/Term.php b/core/modules/taxonomy/src/Entity/Term.php index 77cefcc0dca268ea713ccfea2b2e3263f7ecbd13..8fc3cedb0466a5609d46089d52505d01df94578c 100644 --- a/core/modules/taxonomy/src/Entity/Term.php +++ b/core/modules/taxonomy/src/Entity/Term.php @@ -4,10 +4,12 @@ use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\EntityChangedTrait; +use Drupal\Core\Entity\EntityPublishedTrait; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; use Drupal\taxonomy\TermInterface; +use Drupal\user\StatusItem; /** * Defines the taxonomy term entity. @@ -45,7 +47,8 @@ * "bundle" = "vid", * "label" = "name", * "langcode" = "langcode", - * "uuid" = "uuid" + * "uuid" = "uuid", + * "published" = "status", * }, * bundle_entity_type = "taxonomy_vocabulary", * field_ui_base_route = "entity.taxonomy_vocabulary.overview_form", @@ -62,6 +65,7 @@ class Term extends ContentEntityBase implements TermInterface { use EntityChangedTrait; + use EntityPublishedTrait; /** * {@inheritdoc} @@ -116,6 +120,12 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { /** @var \Drupal\Core\Field\BaseFieldDefinition[] $fields */ $fields = parent::baseFieldDefinitions($entity_type); + // Add the published field. + $fields += static::publishedBaseFieldDefinitions($entity_type); + // @todo Remove the usage of StatusItem in + // https://www.drupal.org/project/drupal/issues/2936864. + $fields['status']->getItemDefinition()->setClass(StatusItem::class); + $fields['tid']->setLabel(t('Term ID')) ->setDescription(t('The term ID.')); diff --git a/core/modules/taxonomy/src/Plugin/EntityReferenceSelection/TermSelection.php b/core/modules/taxonomy/src/Plugin/EntityReferenceSelection/TermSelection.php index a312111eabef0fdf2b57de9de28bd31ed8a92cb0..059225029d74b9e26e92f95764f30d73fae6f469 100644 --- a/core/modules/taxonomy/src/Plugin/EntityReferenceSelection/TermSelection.php +++ b/core/modules/taxonomy/src/Plugin/EntityReferenceSelection/TermSelection.php @@ -59,10 +59,17 @@ public function getReferenceableEntities($match = NULL, $match_operator = 'CONTA $bundles = $this->entityManager->getBundleInfo('taxonomy_term'); $bundle_names = $this->getConfiguration()['target_bundles'] ?: array_keys($bundles); + $has_admin_access = $this->currentUser->hasPermission('administer taxonomy'); + $unpublished_terms = []; foreach ($bundle_names as $bundle) { if ($vocabulary = Vocabulary::load($bundle)) { + /** @var \Drupal\taxonomy\TermInterface[] $terms */ if ($terms = $this->entityManager->getStorage('taxonomy_term')->loadTree($vocabulary->id(), 0, NULL, TRUE)) { foreach ($terms as $term) { + if (!$has_admin_access && (!$term->isPublished() || in_array($term->parent->target_id, $unpublished_terms))) { + $unpublished_terms[] = $term->id(); + continue; + } $options[$vocabulary->id()][$term->id()] = str_repeat('-', $term->depth) . Html::escape($this->entityManager->getTranslationFromContext($term)->label()); } } @@ -72,4 +79,63 @@ public function getReferenceableEntities($match = NULL, $match_operator = 'CONTA return $options; } + /** + * {@inheritdoc} + */ + public function countReferenceableEntities($match = NULL, $match_operator = 'CONTAINS') { + if ($match) { + return parent::countReferenceableEntities($match, $match_operator); + } + + $total = 0; + $referenceable_entities = $this->getReferenceableEntities($match, $match_operator, 0); + foreach ($referenceable_entities as $bundle => $entities) { + $total += count($entities); + } + return $total; + } + + /** + * {@inheritdoc} + */ + protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') { + $query = parent::buildEntityQuery($match, $match_operator); + + // Adding the 'taxonomy_term_access' tag is sadly insufficient for terms: + // core requires us to also know about the concept of 'published' and + // 'unpublished'. + if (!$this->currentUser->hasPermission('administer taxonomy')) { + $query->condition('status', 1); + } + return $query; + } + + /** + * {@inheritdoc} + */ + public function createNewEntity($entity_type_id, $bundle, $label, $uid) { + $term = parent::createNewEntity($entity_type_id, $bundle, $label, $uid); + + // In order to create a referenceable term, it needs to published. + /** @var \Drupal\taxonomy\TermInterface $term */ + $term->setPublished(); + + return $term; + } + + /** + * {@inheritdoc} + */ + public function validateReferenceableNewEntities(array $entities) { + $entities = parent::validateReferenceableNewEntities($entities); + // Mirror the conditions checked in buildEntityQuery(). + if (!$this->currentUser->hasPermission('administer taxonomy')) { + $entities = array_filter($entities, function ($term) { + /** @var \Drupal\taxonomy\TermInterface $term */ + return $term->isPublished(); + }); + } + return $entities; + } + } diff --git a/core/modules/taxonomy/src/TermAccessControlHandler.php b/core/modules/taxonomy/src/TermAccessControlHandler.php index 1d48463666760c866158b13aa515561e24c9e464..b25dca4627b9fb4d9a54733f617b2ba2c1763fee 100644 --- a/core/modules/taxonomy/src/TermAccessControlHandler.php +++ b/core/modules/taxonomy/src/TermAccessControlHandler.php @@ -18,19 +18,37 @@ class TermAccessControlHandler extends EntityAccessControlHandler { * {@inheritdoc} */ protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) { + if ($account->hasPermission('administer taxonomy')) { + return AccessResult::allowed()->cachePerPermissions(); + } + switch ($operation) { case 'view': - return AccessResult::allowedIfHasPermission($account, 'access content'); + $access_result = AccessResult::allowedIf($account->hasPermission('access content') && $entity->isPublished()) + ->cachePerPermissions() + ->addCacheableDependency($entity); + if (!$access_result->isAllowed()) { + $access_result->setReason("The 'access content' permission is required and the taxonomy term must be published."); + } + return $access_result; case 'update': - return AccessResult::allowedIfHasPermissions($account, ["edit terms in {$entity->bundle()}", 'administer taxonomy'], 'OR'); + if ($account->hasPermission("edit terms in {$entity->bundle()}")) { + return AccessResult::allowed()->cachePerPermissions(); + } + + return AccessResult::neutral()->setReason("The following permissions are required: 'edit terms in {$entity->bundle()}' OR 'administer taxonomy'."); case 'delete': - return AccessResult::allowedIfHasPermissions($account, ["delete terms in {$entity->bundle()}", 'administer taxonomy'], 'OR'); + if ($account->hasPermission("delete terms in {$entity->bundle()}")) { + return AccessResult::allowed()->cachePerPermissions(); + } + + return AccessResult::neutral()->setReason("The following permissions are required: 'delete terms in {$entity->bundle()}' OR 'administer taxonomy'."); default: // No opinion. - return AccessResult::neutral(); + return AccessResult::neutral()->cachePerPermissions(); } } diff --git a/core/modules/taxonomy/src/TermInterface.php b/core/modules/taxonomy/src/TermInterface.php index 4cde8f45f684148c1d38b0e65c404207473627b6..2dc26fb65dc4495f2620dae0911c2a06a878e112 100644 --- a/core/modules/taxonomy/src/TermInterface.php +++ b/core/modules/taxonomy/src/TermInterface.php @@ -4,11 +4,12 @@ use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityChangedInterface; +use Drupal\Core\Entity\EntityPublishedInterface; /** * Provides an interface defining a taxonomy term entity. */ -interface TermInterface extends ContentEntityInterface, EntityChangedInterface { +interface TermInterface extends ContentEntityInterface, EntityChangedInterface, EntityPublishedInterface { /** * Gets the term's description. diff --git a/core/modules/taxonomy/taxonomy.install b/core/modules/taxonomy/taxonomy.install index c1a18bcb2cec70eecceced69e04ea311647ebdbc..c0e292db29945b3ee4864d767b5fd841fa1f4a00 100644 --- a/core/modules/taxonomy/taxonomy.install +++ b/core/modules/taxonomy/taxonomy.install @@ -5,6 +5,8 @@ * Install, update and uninstall functions for the taxonomy module. */ +use Drupal\Core\Field\BaseFieldDefinition; + /** * Convert the custom taxonomy term hierarchy storage to a default storage. */ @@ -126,3 +128,50 @@ function taxonomy_update_8503() { } } } + +/** + * Add the publishing status fields to taxonomy terms. + */ +function taxonomy_update_8601() { + $definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + $entity_type = $definition_update_manager->getEntityType('taxonomy_term'); + + // Bail out early if a field named 'status' is already installed. + if ($definition_update_manager->getFieldStorageDefinition('status', 'taxonomy_term')) { + return t('The publishing status field has not been added to taxonomy terms. See this page for more information on how to install it.', [ + ':link' => 'https://www.drupal.org/node/2985366', + ]); + } + + // Add the 'published' entity key to the taxonomy_term entity type. + $entity_keys = $entity_type->getKeys(); + $entity_keys['published'] = 'status'; + $entity_type->set('entity_keys', $entity_keys); + + $definition_update_manager->updateEntityType($entity_type); + + // Add the status field. + $status = BaseFieldDefinition::create('boolean') + ->setLabel(t('Publishing status')) + ->setDescription(t('A boolean indicating the published state.')) + ->setRevisionable(TRUE) + ->setTranslatable(TRUE) + ->setDefaultValue(TRUE); + + $has_content_translation_status_field = \Drupal::moduleHandler()->moduleExists('content_translation') && $definition_update_manager->getFieldStorageDefinition('content_translation_status', 'taxonomy_term'); + if ($has_content_translation_status_field) { + $status->setInitialValueFromField('content_translation_status', TRUE); + } + else { + $status->setInitialValue(TRUE); + } + $definition_update_manager->installFieldStorageDefinition('status', 'taxonomy_term', 'taxonomy_term', $status); + + // Uninstall the 'content_translation_status' field if needed. + if ($has_content_translation_status_field) { + $content_translation_status = $definition_update_manager->getFieldStorageDefinition('content_translation_status', 'taxonomy_term'); + $definition_update_manager->uninstallFieldStorageDefinition($content_translation_status); + } + + return t('The publishing status field has been added to taxonomy terms.'); +} diff --git a/core/modules/taxonomy/taxonomy.post_update.php b/core/modules/taxonomy/taxonomy.post_update.php index ce1d870a3f0be38066c2c8d290a32e73450aa2ea..12a596278306de8d41e109d1fd5cbd8f71447c83 100644 --- a/core/modules/taxonomy/taxonomy.post_update.php +++ b/core/modules/taxonomy/taxonomy.post_update.php @@ -5,6 +5,9 @@ * Post update functions for Taxonomy. */ +use Drupal\Core\Config\Entity\ConfigEntityUpdater; +use Drupal\views\ViewExecutable; + /** * Clear caches due to updated taxonomy entity views data. */ @@ -18,3 +21,107 @@ function taxonomy_post_update_clear_views_data_cache() { function taxonomy_post_update_clear_entity_bundle_field_definitions_cache() { // An empty update will flush caches. } + +/** + * Add a 'published' = TRUE filter for all Taxonomy term views and converts + * existing ones that were using the 'content_translation_status' field. + */ +function taxonomy_post_update_handle_publishing_status_addition_in_views(&$sandbox = NULL) { + $definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + $entity_type = $definition_update_manager->getEntityType('taxonomy_term'); + $published_key = $entity_type->getKey('published'); + + $status_filter = [ + 'id' => 'status', + 'table' => 'taxonomy_term_field_data', + 'field' => $published_key, + 'relationship' => 'none', + 'group_type' => 'group', + 'admin_label' => '', + 'operator' => '=', + 'value' => '1', + 'group' => 1, + 'exposed' => FALSE, + 'expose' => [ + 'operator_id' => '', + 'label' => '', + 'description' => '', + 'use_operator' => FALSE, + 'operator' => '', + 'identifier' => '', + 'required' => FALSE, + 'remember' => FALSE, + 'multiple' => FALSE, + 'remember_roles' => [ + 'authenticated' => 'authenticated', + 'anonymous' => '0', + 'administrator' => '0', + ], + ], + 'is_grouped' => FALSE, + 'group_info' => [ + 'label' => '', + 'description' => '', + 'identifier' => '', + 'optional' => TRUE, + 'widget' => 'select', + 'multiple' => FALSE, + 'remember' => FALSE, + 'default_group' => 'All', + 'default_group_multiple' => [], + 'group_items' => [], + ], + 'entity_type' => 'taxonomy_term', + 'entity_field' => $published_key, + 'plugin_id' => 'boolean', + ]; + + \Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'view', function ($view) use ($published_key, $status_filter) { + /** @var \Drupal\views\ViewEntityInterface $view */ + // Only alter taxonomy term views. + if ($view->get('base_table') !== 'taxonomy_term_field_data') { + return FALSE; + } + + $displays = $view->get('display'); + foreach ($displays as $display_name => &$display) { + // Update any existing 'content_translation_status fields. + $fields = isset($display['display_options']['fields']) ? $display['display_options']['fields'] : []; + foreach ($fields as $id => $field) { + if (isset($field['field']) && $field['field'] == 'content_translation_status') { + $fields[$id]['field'] = $published_key; + } + } + $display['display_options']['fields'] = $fields; + + // Update any existing 'content_translation_status sorts. + $sorts = isset($display['display_options']['sorts']) ? $display['display_options']['sorts'] : []; + foreach ($sorts as $id => $sort) { + if (isset($sort['field']) && $sort['field'] == 'content_translation_status') { + $sorts[$id]['field'] = $published_key; + } + } + $display['display_options']['sorts'] = $sorts; + + // Update any existing 'content_translation_status' filters or add a new + // one if necessary. + $filters = isset($display['display_options']['filters']) ? $display['display_options']['filters'] : []; + $has_status_filter = FALSE; + foreach ($filters as $id => $filter) { + if (isset($filter['field']) && $filter['field'] == 'content_translation_status') { + $filters[$id]['field'] = $published_key; + $has_status_filter = TRUE; + } + } + + if (!$has_status_filter) { + $status_filter['id'] = ViewExecutable::generateHandlerId($published_key, $filters); + $filters[$status_filter['id']] = $status_filter; + } + $display['display_options']['filters'] = $filters; + } + $view->set('display', $displays); + + return TRUE; + }); +} diff --git a/core/modules/taxonomy/tests/fixtures/update/drupal-8.views-taxonomy-term-publishing-status-2981887.php b/core/modules/taxonomy/tests/fixtures/update/drupal-8.views-taxonomy-term-publishing-status-2981887.php new file mode 100644 index 0000000000000000000000000000000000000000..13374dbb7ff6e678ddc43141614577618f35dc56 --- /dev/null +++ b/core/modules/taxonomy/tests/fixtures/update/drupal-8.views-taxonomy-term-publishing-status-2981887.php @@ -0,0 +1,32 @@ +insert('config') + ->fields(['collection', 'name', 'data']) + ->values([ + 'collection' => '', + 'name' => 'views.view.test_taxonomy_term_view_with_content_translation_status', + 'data' => serialize($view_with_cts_config), + ]) + ->values([ + 'collection' => '', + 'name' => 'views.view.test_taxonomy_term_view_without_content_translation_status', + 'data' => serialize($view_without_cts_config), + ]) + ->execute(); diff --git a/core/modules/taxonomy/tests/fixtures/update/views.view.test_taxonomy_term_view_with_content_translation_status.yml b/core/modules/taxonomy/tests/fixtures/update/views.view.test_taxonomy_term_view_with_content_translation_status.yml new file mode 100644 index 0000000000000000000000000000000000000000..f4cc45b12c3813b3079af7774a88fca1291090a3 --- /dev/null +++ b/core/modules/taxonomy/tests/fixtures/update/views.view.test_taxonomy_term_view_with_content_translation_status.yml @@ -0,0 +1,250 @@ +langcode: en +status: true +dependencies: + module: + - taxonomy + - user +id: test_taxonomy_term_view_with_content_translation_status +label: 'Test taxonomy term view with content translation status' +module: views +description: '' +tag: '' +base_table: taxonomy_term_field_data +base_field: tid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'access content' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: none + options: + offset: 0 + style: + type: default + options: + grouping: { } + row_class: '' + default_row_class: true + uses_fields: false + row: + type: fields + options: + inline: { } + separator: '' + hide_empty: false + default_field_elements: true + fields: + name: + id: name + table: taxonomy_term_field_data + field: name + entity_type: taxonomy_term + entity_field: name + label: '' + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + type: string + settings: + link_to_entity: true + plugin_id: term_name + relationship: none + group_type: group + admin_label: '' + exclude: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_alter_empty: true + click_sort_column: value + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + convert_spaces: false + content_translation_status: + id: content_translation_status + table: taxonomy_term_field_data + field: content_translation_status + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: boolean + settings: + format: true-false + format_custom_true: '' + format_custom_false: '' + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: taxonomy_term + entity_field: content_translation_status + plugin_id: field + filters: + content_translation_status: + id: content_translation_status + table: taxonomy_term_field_data + field: content_translation_status + relationship: none + group_type: group + admin_label: '' + operator: '=' + value: All + group: 1 + exposed: true + expose: + operator_id: '' + label: 'Translation status' + description: '' + use_operator: false + operator: content_translation_status_op + identifier: content_translation_status + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: taxonomy_term + entity_field: content_translation_status + plugin_id: boolean + sorts: + content_translation_status: + id: content_translation_status + table: taxonomy_term_field_data + field: content_translation_status + relationship: none + group_type: group + admin_label: '' + order: ASC + exposed: false + expose: + label: '' + entity_type: taxonomy_term + entity_field: content_translation_status + plugin_id: standard + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - user.permissions + tags: { } diff --git a/core/modules/taxonomy/tests/fixtures/update/views.view.test_taxonomy_term_view_without_content_translation_status.yml b/core/modules/taxonomy/tests/fixtures/update/views.view.test_taxonomy_term_view_without_content_translation_status.yml new file mode 100644 index 0000000000000000000000000000000000000000..53eb09c9bd0cd299660e36139deb296ce32cd37c --- /dev/null +++ b/core/modules/taxonomy/tests/fixtures/update/views.view.test_taxonomy_term_view_without_content_translation_status.yml @@ -0,0 +1,128 @@ +langcode: en +status: true +dependencies: + module: + - taxonomy + - user +id: test_taxonomy_term_view_without_content_translation_status +label: 'Test taxonomy term view without content translation status' +module: views +description: '' +tag: '' +base_table: taxonomy_term_field_data +base_field: tid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'access content' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: none + options: + offset: 0 + style: + type: default + options: + grouping: { } + row_class: '' + default_row_class: true + uses_fields: false + row: + type: fields + options: + inline: { } + separator: '' + hide_empty: false + default_field_elements: true + fields: + name: + id: name + table: taxonomy_term_field_data + field: name + entity_type: taxonomy_term + entity_field: name + label: '' + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + type: string + settings: + link_to_entity: true + plugin_id: term_name + relationship: none + group_type: group + admin_label: '' + exclude: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_alter_empty: true + click_sort_column: value + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + convert_spaces: false + filters: { } + sorts: { } + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - user.permissions + tags: { } diff --git a/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php b/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php index 7e5144e5131238ba9ce2f73a92fd6827f7f3f63c..3a1a5a241d033153188b8afb6c2cb448abc59456 100644 --- a/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php +++ b/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php @@ -200,6 +200,11 @@ protected function getExpectedNormalizedEntity() { 'langcode' => 'en', ], ], + 'status' => [ + [ + 'value' => TRUE, + ], + ], ]; } @@ -237,7 +242,7 @@ protected function getExpectedUnauthorizedAccessMessage($method) { switch ($method) { case 'GET': - return "The 'access content' permission is required."; + return "The 'access content' permission is required and the taxonomy term must be published."; case 'POST': return "The following permissions are required: 'create terms in camelids' OR 'administer taxonomy'."; case 'PATCH': @@ -348,4 +353,13 @@ public function providerTestGetTermWithParent() { ]; } + /** + * {@inheritdoc} + */ + protected function getExpectedUnauthorizedAccessCacheability() { + // @see \Drupal\taxonomy\TermAccessControlHandler::checkAccess() + return parent::getExpectedUnauthorizedAccessCacheability() + ->addCacheTags(['taxonomy_term:1']); + } + } diff --git a/core/modules/taxonomy/tests/src/Functional/TermAccessTest.php b/core/modules/taxonomy/tests/src/Functional/TermAccessTest.php new file mode 100644 index 0000000000000000000000000000000000000000..4836fcd6555caf7faae40391d4dd4e8f11cf684c --- /dev/null +++ b/core/modules/taxonomy/tests/src/Functional/TermAccessTest.php @@ -0,0 +1,124 @@ +assertSession(); + + $vocabulary = $this->createVocabulary(); + + // Create two terms. + $published_term = Term::create([ + 'vid' => $vocabulary->id(), + 'name' => 'Published term', + 'status' => 1, + ]); + $published_term->save(); + $unpublished_term = Term::create([ + 'vid' => $vocabulary->id(), + 'name' => 'Unpublished term', + 'status' => 0, + ]); + $unpublished_term->save(); + + // Start off logged in as admin. + $this->drupalLogin($this->drupalCreateUser(['administer taxonomy'])); + + // Test the 'administer taxonomy' permission. + $this->drupalGet('taxonomy/term/' . $published_term->id()); + $assert_session->statusCodeEquals(200); + $this->assertTermAccess($published_term, 'view', TRUE); + $this->drupalGet('taxonomy/term/' . $unpublished_term->id()); + $assert_session->statusCodeEquals(200); + $this->assertTermAccess($unpublished_term, 'view', TRUE); + + $this->drupalGet('taxonomy/term/' . $published_term->id() . '/edit'); + $assert_session->statusCodeEquals(200); + $this->assertTermAccess($published_term, 'update', TRUE); + $this->drupalGet('taxonomy/term/' . $unpublished_term->id() . '/edit'); + $assert_session->statusCodeEquals(200); + $this->assertTermAccess($unpublished_term, 'update', TRUE); + + $this->drupalGet('taxonomy/term/' . $published_term->id() . '/delete'); + $assert_session->statusCodeEquals(200); + $this->assertTermAccess($published_term, 'delete', TRUE); + $this->drupalGet('taxonomy/term/' . $unpublished_term->id() . '/delete'); + $assert_session->statusCodeEquals(200); + $this->assertTermAccess($unpublished_term, 'delete', TRUE); + + // Test the 'access content' permission. + $this->drupalLogin($this->drupalCreateUser(['access content'])); + + $this->drupalGet('taxonomy/term/' . $published_term->id()); + $assert_session->statusCodeEquals(200); + $this->assertTermAccess($published_term, 'view', TRUE); + + $this->drupalGet('taxonomy/term/' . $unpublished_term->id()); + $assert_session->statusCodeEquals(403); + $this->assertTermAccess($unpublished_term, 'view', FALSE, "The 'access content' permission is required and the taxonomy term must be published."); + + $this->drupalGet('taxonomy/term/' . $published_term->id() . '/edit'); + $assert_session->statusCodeEquals(403); + $this->assertTermAccess($published_term, 'update', FALSE, "The following permissions are required: 'edit terms in {$vocabulary->id()}' OR 'administer taxonomy'."); + $this->drupalGet('taxonomy/term/' . $unpublished_term->id() . '/edit'); + $assert_session->statusCodeEquals(403); + $this->assertTermAccess($unpublished_term, 'update', FALSE, "The following permissions are required: 'edit terms in {$vocabulary->id()}' OR 'administer taxonomy'."); + + $this->drupalGet('taxonomy/term/' . $published_term->id() . '/delete'); + $assert_session->statusCodeEquals(403); + $this->assertTermAccess($published_term, 'delete', FALSE, "The following permissions are required: 'delete terms in {$vocabulary->id()}' OR 'administer taxonomy'."); + $this->drupalGet('taxonomy/term/' . $unpublished_term->id() . '/delete'); + $assert_session->statusCodeEquals(403); + $this->assertTermAccess($unpublished_term, 'delete', FALSE, "The following permissions are required: 'delete terms in {$vocabulary->id()}' OR 'administer taxonomy'."); + + // Install the Views module and repeat the checks for the 'view' permission. + \Drupal::service('module_installer')->install(['views'], TRUE); + $this->rebuildContainer(); + + $this->drupalGet('taxonomy/term/' . $published_term->id()); + $assert_session->statusCodeEquals(200); + + // @todo Change this assertion to expect a 403 status code when + // https://www.drupal.org/project/drupal/issues/2983070 is fixed. + $this->drupalGet('taxonomy/term/' . $unpublished_term->id()); + $assert_session->statusCodeEquals(404); + } + + /** + * Checks access on taxonomy term. + * + * @param \Drupal\taxonomy\TermInterface $term + * A taxonomy term entity. + * @param $access_operation + * The entity operation, e.g. 'view', 'edit', 'delete', etc. + * @param bool $access_allowed + * Whether the current use has access to the given operation or not. + * @param string $access_reason + * (optional) The reason of the access result. + */ + protected function assertTermAccess(TermInterface $term, $access_operation, $access_allowed, $access_reason = '') { + $access_result = $term->access($access_operation, NULL, TRUE); + $this->assertSame($access_allowed, $access_result->isAllowed()); + + if ($access_reason) { + $this->assertSame($access_reason, $access_result->getReason()); + } + } + +} diff --git a/core/modules/taxonomy/tests/src/Functional/Update/TaxonomyTermUpdatePathTest.php b/core/modules/taxonomy/tests/src/Functional/Update/TaxonomyTermUpdatePathTest.php new file mode 100644 index 0000000000000000000000000000000000000000..87ad2a7b380dff2b0a703ec3db16c63f0746fe27 --- /dev/null +++ b/core/modules/taxonomy/tests/src/Functional/Update/TaxonomyTermUpdatePathTest.php @@ -0,0 +1,132 @@ +databaseDumpFiles = [ + __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.filled.standard.php.gz', + __DIR__ . '/../../../fixtures/update/drupal-8.views-taxonomy-term-publishing-status-2981887.php', + ]; + } + + /** + * Tests the conversion of taxonomy terms to be publishable. + * + * @see taxonomy_update_8601() + */ + public function testPublishable() { + $this->runUpdates(); + + // Log in as user 1. + $account = User::load(1); + $account->passRaw = 'drupal'; + $this->drupalLogin($account); + + // Make sure our vocabulary exists. + $this->drupalGet('admin/structure/taxonomy/manage/test_vocabulary/overview'); + + // Make sure our terms exist. + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Test root term'); + $assert_session->pageTextContains('Test child term'); + + $this->drupalGet('taxonomy/term/3'); + $assert_session->statusCodeEquals('200'); + + // Make sure the terms are still translated. + $this->drupalGet('taxonomy/term/2/translations'); + $assert_session->linkExists('Test root term - Spanish'); + + $storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term'); + + // Check that the 'content_translation_status' field has been updated + // correctly. + /** @var \Drupal\taxonomy\TermInterface $term */ + $term = $storage->load(2); + $translation = $term->getTranslation('es'); + $this->assertTrue($translation->isPublished()); + + // Check that taxonomy terms can be created, saved and then loaded. + $term = $storage->create([ + 'name' => 'Test term', + 'vid' => 'tags', + ]); + $term->save(); + + $term = $storage->loadUnchanged($term->id()); + + $this->assertEquals('Test term', $term->label()); + $this->assertEquals('tags', $term->bundle()); + $this->assertTrue($term->isPublished()); + + // Check that the term can be unpublished. + $term->setUnpublished(); + $term->save(); + $term = $storage->loadUnchanged($term->id()); + $this->assertFalse($term->isPublished()); + } + + /** + * Tests handling of the publishing status in taxonomy term views updates. + * + * @see taxonomy_post_update_handle_publishing_status_addition_in_views() + */ + public function testPublishingStatusUpdateForTaxonomyTermViews() { + // Check that the test view was previously using the + // 'content_translation_status' field. + $config = \Drupal::config('views.view.test_taxonomy_term_view_with_content_translation_status'); + $display_options = $config->get('display.default.display_options'); + $this->assertEquals('content_translation_status', $display_options['fields']['content_translation_status']['field']); + $this->assertEquals('content_translation_status', $display_options['filters']['content_translation_status']['field']); + $this->assertEquals('content_translation_status', $display_options['sorts']['content_translation_status']['field']); + + // Check a test view without any filter. + $config = \Drupal::config('views.view.test_taxonomy_term_view_without_content_translation_status'); + $display_options = $config->get('display.default.display_options'); + $this->assertEmpty($display_options['filters']); + + $this->runUpdates(); + + // Check that a view which had a field, filter and a sort on the + // 'content_translation_status' field has been updated to use the new + // 'status' field. + $view = View::load('test_taxonomy_term_view_with_content_translation_status'); + foreach ($view->get('display') as $display) { + $this->assertEquals('status', $display['display_options']['fields']['content_translation_status']['field']); + $this->assertEquals('status', $display['display_options']['sorts']['content_translation_status']['field']); + $this->assertEquals('status', $display['display_options']['filters']['content_translation_status']['field']); + } + + // Check that a view without any filters has been updated to include a + // filter for the 'status' field. + $view = View::load('test_taxonomy_term_view_without_content_translation_status'); + foreach ($view->get('display') as $display) { + $this->assertNotEmpty($display['display_options']['filters']); + $this->assertEquals('status', $display['display_options']['filters']['status']['field']); + } + } + + /** + * {@inheritdoc} + */ + protected function replaceUser1() { + // Do not replace the user from our dump. + } + +}