diff --git a/config/sync/views.view.asset_distribution_downloads.yml b/config/sync/views.view.asset_distribution_downloads.yml index 89dd4ce18b..d0473ada7e 100644 --- a/config/sync/views.view.asset_distribution_downloads.yml +++ b/config/sync/views.view.asset_distribution_downloads.yml @@ -449,6 +449,136 @@ display: hide_alter_empty: true link_to_entity: true plugin_id: entity_label + parent_entity_type: + id: parent_entity_type + table: joinup_download_event + field: parent_entity_type + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: true + 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: string + settings: + link_to_entity: 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: download_event + entity_field: parent_entity_type + plugin_id: field + parent_entity_id: + id: parent_entity_id + table: joinup_download_event + field: parent_entity_id + relationship: none + group_type: group + admin_label: '' + label: 'Parent' + 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: true + 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: string + settings: + link_to_entity: 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: download_event + entity_field: parent_entity_id + plugin_id: field created: id: created table: joinup_download_event diff --git a/tests/features/asset_distribution/track_download.feature b/tests/features/asset_distribution/track_download.feature index bc77c3c137..76a6496165 100644 --- a/tests/features/asset_distribution/track_download.feature +++ b/tests/features/asset_distribution/track_download.feature @@ -110,7 +110,7 @@ Feature: Asset distribution editing. When I go to the "Winter of 95" release Then I should see the link "External" in the "i386" tile - Scenario: Tests the CSV download. + Scenario: Download a CSV export of the distributions download report. Given users: | Username | E-mail | | user1 | user1@example.com | @@ -118,11 +118,15 @@ Feature: Asset distribution editing. And the following solution: | title | Solution | | state | validated | + And the following release: + | title | Release 1 | + | is version of | Solution | + | state | validated | And the following distributions: - | title | parent | access url | - | Distribution 1 | Solution | text.pdf | - | Distribution 2 | Solution | test.zip | - | Distribution 3 | Solution | test1.zip | + | title | parent | access url | + | Distribution 1 | Release 1 | text.pdf | + | Distribution 2 | Solution | test.zip | + | Distribution 3 | Solution | test1.zip | And the following distribution download events: | distribution | user | | Distribution 1 | visitor@example.com | @@ -139,9 +143,10 @@ Feature: Asset distribution editing. Then I should see the success message "Export complete. Download the file here if file is not automatically downloaded." And I should see the link "here" And the file downloaded from the "here" link contains the following strings: - | ID,User,Email,"File name",Distribution,Created | - | ,"Anonymous (not verified)",visitor@example.com,text.pdf,"Distribution 1", | - | ,user1,user1@example.com,text.pdf,"Distribution 1", | - | ,user2,user2@example.com,test.zip,"Distribution 2", | - | ,"Anonymous (not verified)",anon@example.com,test1.zip,"Distribution 3", | - | ,user1,user1@example.com,test1.zip,"Distribution 3", | + | ID,User,Email,"File name",Distribution,Parent,Created | + # The %title% variable will translate the title to the entity ID since that is what is exported in the CSV file. + | ,"Anonymous (not verified)",visitor@example.com,text.pdf,"Distribution 1",%Release 1%, | + | ,user1,user1@example.com,text.pdf,"Distribution 1",%Release 1%, | + | ,user2,user2@example.com,test.zip,"Distribution 2",%Solution%, | + | ,"Anonymous (not verified)",anon@example.com,test1.zip,"Distribution 3",%Solution%, | + | ,user1,user1@example.com,test1.zip,"Distribution 3",%Solution%, | diff --git a/tests/features/bootstrap/FeatureContext.php b/tests/features/bootstrap/FeatureContext.php index 074c50bba8..d5163322fb 100644 --- a/tests/features/bootstrap/FeatureContext.php +++ b/tests/features/bootstrap/FeatureContext.php @@ -2006,11 +2006,20 @@ public function assertDownloadedFileContainsStrings(string $link_label, TableNod throw new \Exception("The downloaded file has no content."); } - $not_found = array_filter($strings_table->getColumn(0), function (string $text) use ($content): bool { - return strpos($content, $text) === FALSE; - }); + $not_found = []; + foreach ($strings_table->getColumn(0) as $text) { + $matches = []; + if (preg_match('/^.*%(.*?)%.*$/', $text, $matches)) { + $entity = $this->getEntityByLabel('rdf_entity', $matches[1]); + $text = str_replace("%{$matches[1]}%", $entity->id(), $text); + } - if ($not_found) { + if (strpos($content, $text) === FALSE) { + $not_found[] = $text; + } + } + + if (!empty($not_found)) { throw new ExpectationFailedException("Following strings were not found in the downloaded file:\n- " . implode("\n- ", $not_found)); } } diff --git a/tests/src/Context/AssetDistributionContext.php b/tests/src/Context/AssetDistributionContext.php index a3ac36948b..f2ba05671f 100644 --- a/tests/src/Context/AssetDistributionContext.php +++ b/tests/src/Context/AssetDistributionContext.php @@ -483,15 +483,19 @@ public function clickLinkInCompactDistribution(string $link, string $heading): v */ public function downloadEvents(TableNode $table): void { foreach ($table->getColumnsHash() as $row) { + /** @var \Drupal\asset_distribution\Entity\AssetDistributionInterface $distribution */ $distribution = $this->getRdfEntityByLabel($row['distribution'], 'asset_distribution'); - /** @var \Drupal\file\FileInterface $file */ $file = FileUrlHandler::urlToFile($distribution->get('field_ad_access_url')->target_id); $account = \Drupal::service('email.validator')->isValid($row['user']) ? new AnonymousUserSession() : user_load_by_name($row['user']); $mail = $account->isAnonymous() ? $row['user'] : $account->getEmail(); + + $parent = $distribution->getParent(); $entity = DownloadEvent::create([ 'uid' => $account->id(), 'mail' => $mail, 'file' => $file->id(), + 'parent_entity_type' => $parent->getEntityTypeId(), + 'parent_entity_id' => $parent->id(), ]); $entity->save(); $this->entities['download_event'][$entity->id()] = $entity; diff --git a/web/modules/custom/asset_distribution/asset_distribution.module b/web/modules/custom/asset_distribution/asset_distribution.module index 9f9b2265aa..06ca4bf71c 100644 --- a/web/modules/custom/asset_distribution/asset_distribution.module +++ b/web/modules/custom/asset_distribution/asset_distribution.module @@ -309,3 +309,38 @@ function asset_distribution_rdf_entity_delete(RdfInterface $distribution) { } } + +/** + * Implements hook_preprocess_HOOK(). + */ +function asset_distribution_preprocess_views_view_field(&$variables) { + $view = $variables['view']; + $field = $variables['field']; + if ($view->storage->id() !== 'asset_distribution_downloads' || $view->current_display !== 'page' || $field->field !== 'parent_entity_id') { + return; + } + + /** @var \Drupal\asset_distribution\Entity\DownloadEvent $download_event */ + $download_event = $variables['row']->_entity; + if ($download_event->get('parent_entity_type')->isEmpty() || $download_event->get('parent_entity_id')->isEmpty()) { + return; + } + + // Notice: This code works for any entity type, though it is only meant for + // distributions so it might be too much. + $entity_type = $download_event->get('parent_entity_type')->value; + $entity_id = $download_event->get('parent_entity_id')->value; + + try { + $storage = $storages[$entity_type] ?? \Drupal::entityTypeManager()->getStorage($entity_type); + } + catch (Exception $exception) { + return; + } + + $storages[$entity_type] = $storage; + $entity = $storage->load($entity_id); + if (!empty($entity)) { + $variables['output'] = $entity->toLink($entity->label()); + } +} diff --git a/web/modules/custom/asset_distribution/src/Controller/DownloadTrackingController.php b/web/modules/custom/asset_distribution/src/Controller/DownloadTrackingController.php index 9d0cfd055b..d2ba897c0d 100644 --- a/web/modules/custom/asset_distribution/src/Controller/DownloadTrackingController.php +++ b/web/modules/custom/asset_distribution/src/Controller/DownloadTrackingController.php @@ -12,7 +12,9 @@ use Drupal\Core\Session\AccountInterface; use Drupal\asset_distribution\Form\AnonymousDownloadForm; use Drupal\file\FileInterface; +use Drupal\file\FileUsage\FileUsageInterface; use Drupal\file_url\FileUrlHandler; +use Drupal\sparql_entity_storage\SparqlEntityStorageInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Response; @@ -49,10 +51,17 @@ class DownloadTrackingController extends ControllerBase { */ protected $sparqlStorage; + /** + * The file usage service. + * + * @var \Drupal\file\FileUsage\FileUsageInterface + */ + protected $fileUsage; + /** * Instantiates a new DownloadTrackingController object. * - * @param \Drupal\Core\Entity\ContentEntityStorageInterface $sparql_storage + * @param \Drupal\sparql_entity_storage\SparqlEntityStorageInterface $sparql_storage * The RDF entity storage. * @param \Drupal\Core\Entity\ContentEntityStorageInterface $event_storage * The download event entity storage. @@ -62,13 +71,16 @@ class DownloadTrackingController extends ControllerBase { * The form builder. * @param \Drupal\Core\Session\AccountInterface $current_user * The current logged in user. + * @param \Drupal\file\FileUsage\FileUsageInterface $file_usage + * The file usage service. */ - public function __construct(ContentEntityStorageInterface $sparql_storage, ContentEntityStorageInterface $event_storage, FileUrlHandler $file_url_handler, FormBuilderInterface $form_builder, AccountInterface $current_user) { + public function __construct(SparqlEntityStorageInterface $sparql_storage, ContentEntityStorageInterface $event_storage, FileUrlHandler $file_url_handler, FormBuilderInterface $form_builder, AccountInterface $current_user, FileUsageInterface $file_usage) { $this->sparqlStorage = $sparql_storage; $this->eventStorage = $event_storage; $this->fileUrlHandler = $file_url_handler; $this->formBuilder = $form_builder; $this->currentUser = $current_user; + $this->fileUsage = $file_usage; } /** @@ -80,7 +92,8 @@ public static function create(ContainerInterface $container) { $container->get('entity_type.manager')->getStorage('download_event'), $container->get('file_url.handler'), $container->get('form_builder'), - $container->get('current_user') + $container->get('current_user'), + $container->get('file.usage') ); } @@ -124,9 +137,26 @@ protected function trackAnonymousDownload(FileInterface $file) { * The generated response. */ protected function trackAuthenticatedDownload(FileInterface $file) { + $usages = $this->fileUsage->listUsage($file); + + // Normally, only one distribution is allowed to use a file and only + // distributions call this code. + if (empty($usages['file']['rdf_entity'])) { + throw new \RuntimeException('No distributions were found using the file with ID ' . $file->id()); + } + if (count($usages['file']['rdf_entity']) > 1) { + throw new \RuntimeException('More than one distributions were found for the file with ID ' . $file->id()); + } + $distribution = $this->sparqlStorage->load(key($usages['file']['rdf_entity'])); + + /** @var \Drupal\solution\Entity\SolutionInterface|\Drupal\asset_release\Entity\AssetReleaseInterface $parent */ + $parent = $distribution->getParent(); + $event = $this->eventStorage->create([ 'uid' => $this->currentUser->id(), 'file' => $file->id(), + 'parent_entity_type' => $parent->getEntityTypeId(), + 'parent_entity_id' => $parent->id(), ]); $event->save(); diff --git a/web/modules/custom/asset_distribution/src/Entity/AssetDistribution.php b/web/modules/custom/asset_distribution/src/Entity/AssetDistribution.php index d0aa76a950..a729327595 100644 --- a/web/modules/custom/asset_distribution/src/Entity/AssetDistribution.php +++ b/web/modules/custom/asset_distribution/src/Entity/AssetDistribution.php @@ -36,12 +36,12 @@ class AssetDistribution extends Rdf implements AssetDistributionInterface { /** * {@inheritdoc} */ - public function getParent() { + public function getParent(): DistributionsParentInterface { /** @var \Drupal\asset_distribution\DistributionParentFieldItemList $field */ $field = $this->get('parent'); /** @var \Drupal\asset_release\Entity\AssetReleaseInterface|\Drupal\solution\Entity\SolutionInterface $parent */ - if ($field->isEmpty() || !($parent = $field->entity)) { + if ($field->isEmpty() || !($parent = $field->entity) || !$parent instanceof DistributionsParentInterface) { // During normal operation every distribution should have a parent entity, // so the only way a parent can be missing is because of an unexpected // condition occurring at runtime, for example if a data store goes diff --git a/web/modules/custom/asset_distribution/src/Entity/AssetDistributionInterface.php b/web/modules/custom/asset_distribution/src/Entity/AssetDistributionInterface.php index f1b85cd333..e53ba04c99 100644 --- a/web/modules/custom/asset_distribution/src/Entity/AssetDistributionInterface.php +++ b/web/modules/custom/asset_distribution/src/Entity/AssetDistributionInterface.php @@ -17,7 +17,7 @@ interface AssetDistributionInterface extends RdfInterface, CollectionContentInte /** * Return the distribution's parent, either a release or a solution. * - * @return \Drupal\asset_release\Entity\AssetReleaseInterface|\Drupal\solution\Entity\SolutionInterface + * @return \Drupal\asset_distribution\Entity\DistributionsParentInterface * The parent entity, either a release or a solution. * * @throws \Drupal\asset_distribution\Exception\MissingDistributionParentException @@ -26,7 +26,7 @@ interface AssetDistributionInterface extends RdfInterface, CollectionContentInte * need to catch this exception. This will only be thrown in case something * is seriously wrong, e.g. if the database is down. */ - public function getParent(); + public function getParent(): DistributionsParentInterface; /** * Checks whether the distribution parent is a solution rather than a release. diff --git a/web/modules/custom/asset_distribution/src/Entity/DistributionsParentInterface.php b/web/modules/custom/asset_distribution/src/Entity/DistributionsParentInterface.php index 0c2cae8919..29a5202f29 100644 --- a/web/modules/custom/asset_distribution/src/Entity/DistributionsParentInterface.php +++ b/web/modules/custom/asset_distribution/src/Entity/DistributionsParentInterface.php @@ -4,10 +4,12 @@ namespace Drupal\asset_distribution\Entity; +use Drupal\Core\Entity\ContentEntityInterface; + /** * Bundle class for content having distributions as children. */ -interface DistributionsParentInterface { +interface DistributionsParentInterface extends ContentEntityInterface { /** * Returns the child distribution IDs. diff --git a/web/modules/custom/asset_distribution/src/Entity/DownloadEvent.php b/web/modules/custom/asset_distribution/src/Entity/DownloadEvent.php index 6cafd407a8..db04919f08 100644 --- a/web/modules/custom/asset_distribution/src/Entity/DownloadEvent.php +++ b/web/modules/custom/asset_distribution/src/Entity/DownloadEvent.php @@ -59,6 +59,16 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ->setSetting('target_type', 'file') ->setRequired(TRUE); + $fields['parent_entity_type'] = BaseFieldDefinition::create('string') + ->setLabel(t('Parent entity type')) + ->setDescription(t('The solution or release that the distribution belongs to.')) + ->setRequired(FALSE); + + $fields['parent_entity_id'] = BaseFieldDefinition::create('string') + ->setLabel(t('Parent entity ID')) + ->setDescription(t('The entity ID of the solution or release that the distribution belongs to.')) + ->setRequired(FALSE); + return $fields; } diff --git a/web/modules/custom/asset_distribution/src/Form/AnonymousDownloadForm.php b/web/modules/custom/asset_distribution/src/Form/AnonymousDownloadForm.php index 442d77d7e2..73889e74b0 100644 --- a/web/modules/custom/asset_distribution/src/Form/AnonymousDownloadForm.php +++ b/web/modules/custom/asset_distribution/src/Form/AnonymousDownloadForm.php @@ -13,6 +13,8 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Url; use Drupal\file\FileInterface; +use Drupal\file\FileUsage\FileUsageInterface; +use Drupal\sparql_entity_storage\SparqlEntityStorageInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -21,6 +23,13 @@ */ class AnonymousDownloadForm extends FormBase { + /** + * The RDF entity storage. + * + * @var \Drupal\Core\Entity\ContentEntityStorageInterface + */ + protected $sparqlStorage; + /** * The download_event entity storage. * @@ -28,6 +37,13 @@ class AnonymousDownloadForm extends FormBase { */ protected $eventStorage; + /** + * The file usage service. + * + * @var \Drupal\file\FileUsage\FileUsageInterface + */ + protected $fileUsage; + /** * The file being downloaded. * @@ -38,11 +54,17 @@ class AnonymousDownloadForm extends FormBase { /** * Instantiates a new AnonymousDownloadForm object. * + * @param \Drupal\sparql_entity_storage\SparqlEntityStorageInterface $sparql_storage + * The RDF entity storage. * @param \Drupal\Core\Entity\ContentEntityStorageInterface $event_storage * The download event entity storage. + * @param \Drupal\file\FileUsage\FileUsageInterface $file_usage + * The file usage service. */ - public function __construct(ContentEntityStorageInterface $event_storage) { + public function __construct(SparqlEntityStorageInterface $sparql_storage, ContentEntityStorageInterface $event_storage, FileUsageInterface $file_usage) { + $this->sparqlStorage = $sparql_storage; $this->eventStorage = $event_storage; + $this->fileUsage = $file_usage; } /** @@ -50,7 +72,9 @@ public function __construct(ContentEntityStorageInterface $event_storage) { */ public static function create(ContainerInterface $container) { return new static( - $container->get('entity_type.manager')->getStorage('download_event') + $container->get('entity_type.manager')->getStorage('rdf_entity'), + $container->get('entity_type.manager')->getStorage('download_event'), + $container->get('file.usage') ); } @@ -119,10 +143,26 @@ public function submitForm(array &$form, FormStateInterface $form_state) { throw new BadRequestHttpException(); } + $usages = $this->fileUsage->listUsage($this->file); + + // Normally, only one distribution is allowed to use a file and only + // distributions call this code. + if (empty($usages['file']['rdf_entity'])) { + throw new \RuntimeException('No distributions were found using the file with ID ' . $file->id()); + } + if (count($usages['file']['rdf_entity']) > 1) { + throw new \RuntimeException('More than one distributions were found for the file with ID ' . $file->id()); + } + $distribution = $this->sparqlStorage->load(key($usages['file']['rdf_entity'])); + + /** @var \Drupal\solution\Entity\SolutionInterface|\Drupal\asset_release\Entity\AssetReleaseInterface $parent */ + $parent = $distribution->getParent(); $event = $this->eventStorage->create([ 'uid' => 0, 'mail' => $form_state->getValue('email'), 'file' => $this->file->id(), + 'parent_entity_type' => $parent->getEntityTypeId(), + 'parent_entity_id' => $parent->id(), ]); $event->save(); diff --git a/web/modules/custom/joinup_core/joinup_core.deploy.php b/web/modules/custom/joinup_core/joinup_core.deploy.php index 956869185a..0f6d0b1f0f 100644 --- a/web/modules/custom/joinup_core/joinup_core.deploy.php +++ b/web/modules/custom/joinup_core/joinup_core.deploy.php @@ -14,6 +14,8 @@ declare(strict_types = 1); +use Drupal\asset_distribution\Entity\DownloadEvent; + /** * Switch the filter format of the collection abstract to basic HTML. */ @@ -42,3 +44,50 @@ function joinup_core_deploy_0107000(array &$sandbox): string { return "Processed {$sandbox['processed']} out of {$sandbox['total']}"; } + +/** + * Fill the parent of the distribution downloads. + */ +function joinup_core_deploy_0107001(array &$sandbox): string { + if (empty($sandbox['entity_ids'])) { + $sandbox['entity_ids'] = \Drupal::database()->query('SELECT `id` FROM joinup_download_event')->fetchCol(); + $sandbox['progress'] = 0; + $sandbox['max'] = count($sandbox['entity_ids']); + } + + $file_usage = \Drupal::getContainer()->get('file.usage'); + $sparql_storage = \Drupal::entityTypeManager()->getStorage('rdf_entity'); + $entity_ids = array_splice($sandbox['entity_ids'], 0, 100); + /** @var \Drupal\asset_distribution\Entity\DownloadEvent $download_event */ + foreach (DownloadEvent::loadMultiple($entity_ids) as $download_event) { + $file = $download_event->file->entity; + if (empty($file)) { + continue; + } + + $usages = $file_usage->listUsage($file); + if (empty($usages['file']['rdf_entity'])) { + continue; + } + $distribution = $sparql_storage->load(key($usages['file']['rdf_entity'])); + if (empty($distribution)) { + continue; + } + + try { + $parent = $distribution->getParent(); + } + catch (\Exception $e) { + // We don't want to force anything for old records. + continue; + } + + $download_event->set('parent_entity_type', 'rdf_entity'); + $download_event->set('parent_entity_id', $parent->id()); + $download_event->save(); + } + + $sandbox['progress'] += count($entity_ids); + $sandbox['#finished'] = (float) $sandbox['progress'] / (float) $sandbox['max']; + return "Completed {$sandbox['progress']} out of {$sandbox['max']}."; +} diff --git a/web/modules/custom/joinup_core/joinup_core.install b/web/modules/custom/joinup_core/joinup_core.install index 4f923d06da..d54d5c7709 100644 --- a/web/modules/custom/joinup_core/joinup_core.install +++ b/web/modules/custom/joinup_core/joinup_core.install @@ -91,3 +91,21 @@ function joinup_core_requirements($phase): array { return $requirements; } + +/** + * Install new fields in the download event entity. + */ +function joinup_core_update_0107000(array &$sandbox): void { + $update_manager = \Drupal::entityDefinitionUpdateManager(); + $base_fields = \Drupal::getContainer()->get('entity_field.manager')->getBaseFieldDefinitions('download_event'); + + $fields = [ + 'parent_entity_type', + 'parent_entity_id', + ]; + + foreach ($fields as $field) { + $storage = $base_fields[$field]->getFieldStorageDefinition(); + $update_manager->installFieldStorageDefinition($field, 'download_event', 'download_event', $storage); + } +}